From cf74f09dce92569fcb492fd78bf24a11c3636382 Mon Sep 17 00:00:00 2001 From: Benoit Letondor Date: Wed, 19 Jun 2024 23:32:13 +0200 Subject: [PATCH 1/4] Bump AS --- Android/EasyBudget/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Android/EasyBudget/build.gradle.kts b/Android/EasyBudget/build.gradle.kts index bbc1f6a0..ff75146d 100644 --- a/Android/EasyBudget/build.gradle.kts +++ b/Android/EasyBudget/build.gradle.kts @@ -18,8 +18,8 @@ val hiltVersion by extra("2.51.1") // Change in the plugins below too val realmVersion by extra("2.0.0") // Change in the plugins below too plugins { - id("com.android.application") version "8.4.1" apply false - id("com.android.library") version "8.4.1" apply false + id("com.android.application") version "8.5.0" apply false + id("com.android.library") version "8.5.0" apply false id("com.google.firebase.crashlytics") version "3.0.1" apply false id("com.google.gms.google-services") version "4.4.2" apply false id("org.jetbrains.kotlin.android") version "2.0.0" apply false From cb05e17e4c3ab88c08860007d3210408af1aa292 Mon Sep 17 00:00:00 2001 From: Benoit Letondor Date: Mon, 8 Jul 2024 17:39:53 +0200 Subject: [PATCH 2/4] Migrate UI to Compose (#20) --- Android/EasyBudget/app/build.gradle.kts | 34 +- .../app/src/main/AndroidManifest.xml | 77 +- .../easybudgetapp/EasyBudget.kt | 148 +-- .../easybudgetapp/MainActivity.kt | 301 ++++++ .../accounts/FirebaseAccounts.kt | 2 +- .../benoitletondor/easybudgetapp/auth/Auth.kt | 7 +- .../easybudgetapp/auth/FirebaseAuth.kt | 36 +- .../easybudgetapp/compose/AppNavHost.kt | 459 +++++++++ .../{theme => compose}/AppTheme.kt | 2 +- .../easybudgetapp/compose/AppTopAppBar.kt | 106 ++ .../compose/AppWithTopAppBarScaffold.kt | 40 + .../compose/PushPermissionState.kt | 50 + .../components/ExpenseEditTextField.kt | 148 +++ .../compose/components/LoadingView.kt | 57 ++ .../com/benoitletondor/easybudgetapp/db/DB.kt | 2 + .../db/cacheimpl/CachedDBImpl.kt | 4 + .../db/offlineimpl/OfflineDBImpl.kt | 8 +- .../db/onlineimpl/OnlineDBImpl.kt | 12 +- .../easybudgetapp/helper/BaseFragment.kt | 46 - .../easybudgetapp/helper/CurrencyHelper.kt | 31 +- .../helper/FlowCollectExtension.kt | 42 + .../easybudgetapp/helper/Logger.kt | 8 +- .../helper/RecurringExpenseTypeHelper.kt | 35 + .../easybudgetapp/helper/SerializedExpense.kt | 102 ++ .../helper/SerializedSelectedOnlineAccount.kt | 34 + ...BaseActivity.kt => SerializedYearMonth.kt} | 31 +- .../easybudgetapp/helper/UIHelper.kt | 152 +-- .../easybudgetapp/iab/IabImpl.kt | 11 +- .../easybudgetapp/injection/AppModule.kt | 9 +- .../CurrentDBProvider.kt} | 9 +- .../model/AssociatedRecurringExpense.kt | 28 +- .../easybudgetapp/model/DataForMonth.kt | 2 +- .../easybudgetapp/model/Expense.kt | 40 +- .../easybudgetapp/model/RecurringExpense.kt | 33 +- .../model/RecurringExpenseDeleteType.kt | 19 - .../parameters/ParametersExtension.kt | 135 +++ .../view/DatePickerDialogFragment.kt | 51 - .../CreateAccountView.kt} | 197 ++-- .../createaccount/CreateAccountViewModel.kt | 2 +- .../view/expenseedit/ExpenseEditActivity.kt | 258 ----- .../view/expenseedit/ExpenseEditView.kt | 421 ++++++++ .../view/expenseedit/ExpenseEditViewModel.kt | 212 ++-- .../LoginActivity.kt => login/LoginView.kt} | 202 ++-- .../view/{main => }/login/LoginViewModel.kt | 32 +- .../view/main/FloatingActionButtonBehavior.kt | 54 - .../easybudgetapp/view/main/MainActivity.kt | 427 -------- .../easybudgetapp/view/main/MainView.kt | 941 ++++++++++++++++++ .../easybudgetapp/view/main/MainViewModel.kt | 722 +++++++++++++- .../view/main/account/AccountFragment.kt | 741 -------------- .../view/main/account/AccountViewModel.kt | 622 ------------ .../account/ExpensesRecyclerViewAdapter.kt | 213 ---- .../AccountSelectorFragment.kt | 121 --- .../view/main/loading/LoadingFragment.kt | 36 - .../manageaccount/ManageAccountActivity.kt | 147 --- .../view/main/subviews/DBLoadingErrorView.kt | 73 ++ .../view/main/subviews/ExpensesView.kt | 328 ++++++ .../view/main/subviews/FABMenuOverlay.kt | 126 +++ .../view/main/subviews/MainViewContent.kt | 120 +++ .../view/main/subviews/MonthlyReportHint.kt | 83 ++ .../main/subviews/SelectedAccountHeader.kt | 118 +++ .../view/main/subviews/TopBar.kt | 151 +++ .../accountselector/AccountSelectorView.kt} | 83 +- .../AccountSelectorViewModel.kt | 69 +- .../calendar/CalendarView.kt | 47 +- .../calendar/NumberFormatter.kt | 2 +- .../calendar/views/CalendarDatesView.kt | 11 +- .../calendar/views/CalendarDayView.kt | 8 +- .../calendar/views/CalendarHeaderView.kt | 2 +- .../view/manageaccount/ManageAccountView.kt | 145 +++ .../manageaccount/ManageAccountViewModel.kt | 30 +- .../view => manageaccount/subviews}/Views.kt | 73 +- .../view/monthlyreport/MonthlyReportView.kt | 202 ++++ .../monthlyreport/MonthlyReportViewModel.kt | 203 ++++ .../export/MonthlyReportExportView.kt | 128 +++ .../export/MonthlyReportExportViewModel.kt | 159 +++ .../monthlyreport/export/subviews/Views.kt | 161 +++ .../view/monthlyreport/subviews/EmptyView.kt | 62 ++ .../view/monthlyreport/subviews/Entries.kt | 111 +++ .../view/monthlyreport/subviews/Entry.kt | 151 +++ .../view/monthlyreport/subviews/ErrorView.kt | 73 ++ .../monthlyreport/subviews/MonthHeader.kt | 97 ++ .../view/monthlyreport/subviews/RecapView.kt | 121 +++ .../view/onboarding/OnboardingView.kt | 243 +++++ .../view/onboarding/OnboardingViewModel.kt | 152 +++ .../subviews/OnboardingPageAccountAmount.kt | 188 ++++ .../subviews/OnboardingPageCurrency.kt | 103 ++ .../onboarding/subviews/OnboardingPageEnd.kt | 134 +++ .../OnboardingPagePushNotification.kt | 124 +++ .../subviews/OnboardingPageWelcome.kt | 115 +++ .../view/premium/PremiumActivity.kt | 153 --- .../easybudgetapp/view/premium/PremiumView.kt | 184 ++++ .../view/premium/PremiumViewModel.kt | 93 +- .../view/premium/view/ErrorView.kt | 19 +- .../view/premium/view/SubscribeView.kt | 8 +- .../RecurringExpenseEditActivity.kt | 373 ------- .../RecurringExpenseEditView.kt | 576 +++++++++++ .../RecurringExpenseEditViewModel.kt | 292 ++++-- .../view/report/MonthlyReportFragment.kt | 143 --- .../MonthlyReportRecyclerViewAdapter.kt | 166 --- .../view/report/MonthlyReportViewModel.kt | 95 -- .../report/base/MonthlyReportBaseActivity.kt | 186 ---- .../report/base/MonthlyReportBaseViewModel.kt | 144 --- .../report/export/ExportReportActivity.kt | 348 ------- .../selectcurrency/SelectCurrencyFragment.kt | 119 --- .../SelectCurrencyRecyclerViewAdapter.kt | 120 --- .../view/selectcurrency/SelectCurrencyView.kt | 176 ++++ .../selectcurrency/SelectCurrencyViewModel.kt | 57 +- .../view/settings/PreferencesFragment.kt | 693 ------------- .../view/settings/SettingsActivity.kt | 93 -- .../view/settings/SettingsView.kt | 271 +++++ .../view/settings/SettingsViewModel.kt | 360 +++++++ .../settings/backup/BackupSettingsActivity.kt | 352 ------- .../settings/backup/BackupSettingsView.kt | 266 +++++ .../backup/BackupSettingsViewModel.kt | 262 ++--- .../backup/subviews/AuthenticatedView.kt | 228 +++++ .../backup/subviews/NotAuthenticatedView.kt | 58 ++ .../view/settings/subviews/ErrorView.kt | 76 ++ .../subviews/LowMoneyWarningAmountPicker.kt | 73 ++ .../settings/subviews/RedeemCodeDialog.kt | 76 ++ .../view/settings/subviews/Settings.kt | 275 +++++ .../view/settings/subviews/SettingsButton.kt | 58 ++ .../subviews/SettingsCategoryTitle.kt | 41 + .../settings/subviews/SettingsCheckbox.kt | 77 ++ .../view/settings/subviews/SettingsSwitch.kt | 77 ++ .../settings/subviews/ThemePickerDialog.kt | 144 +++ .../view/welcome/Onboarding1Fragment.kt | 52 - .../view/welcome/Onboarding2Fragment.kt | 97 -- .../view/welcome/Onboarding3Fragment.kt | 152 --- .../view/welcome/Onboarding4Fragment.kt | 52 - .../view/welcome/OnboardingFragment.kt | 68 -- .../OnboardingPushPermissionFragment.kt | 77 -- .../view/welcome/WelcomeActivity.kt | 222 ----- .../main/res/drawable-hdpi/ic_launcher.png | Bin 0 -> 3436 bytes .../main/res/drawable-mdpi/ic_launcher.png | Bin 0 -> 2246 bytes .../main/res/drawable-xhdpi/ic_launcher.png | Bin 0 -> 4873 bytes .../main/res/drawable-xxhdpi/ic_launcher.png | Bin 0 -> 7525 bytes .../main/res/drawable-xxxhdpi/ic_launcher.png | Bin 0 -> 10699 bytes ...alendar_month_switcher_button_drawable.xml | 32 - .../expense_date_background_drawable.xml | 27 - .../res/drawable/expense_date_focused.xml | 31 - .../main/res/drawable/expense_date_normal.xml | 31 - .../expense_interval_spinner_background.xml | 27 - .../expense_interval_spinner_focused.xml | 31 - .../expense_interval_spinner_normal.xml | 31 - .../drawable/fab_action_label_background.xml | 26 - .../drawable/ic_baseline_arrow_drop_up_24.xml | 5 + .../main/res/drawable/triangle_drawable.xml | 32 - .../res/layout/activity_backup_settings.xml | 319 ------ .../res/layout/activity_create_account.xml | 41 - .../main/res/layout/activity_expense_edit.xml | 243 ----- .../src/main/res/layout/activity_login.xml | 41 - .../app/src/main/res/layout/activity_main.xml | 95 -- .../res/layout/activity_monthly_report.xml | 106 -- .../layout/activity_monthly_report_export.xml | 41 - .../src/main/res/layout/activity_premium.xml | 136 --- .../layout/activity_recurring_expense_add.xml | 267 ----- .../src/main/res/layout/activity_settings.xml | 45 - .../src/main/res/layout/activity_welcome.xml | 35 - .../src/main/res/layout/fragment_account.xml | 254 ----- .../res/layout/fragment_account_loading.xml | 32 - .../res/layout/fragment_account_selector.xml | 20 - .../res/layout/fragment_monthly_report.xml | 170 ---- .../main/res/layout/fragment_onboarding1.xml | 95 -- .../main/res/layout/fragment_onboarding2.xml | 82 -- .../main/res/layout/fragment_onboarding3.xml | 114 --- .../main/res/layout/fragment_onboarding4.xml | 112 --- .../fragment_onboarding_push_permission.xml | 111 --- .../res/layout/fragment_select_currency.xml | 30 - ...cyclerview_monthly_report_expense_cell.xml | 105 -- ...ecyclerview_monthly_report_header_cell.xml | 34 - .../res/layout/recycleview_currency_cell.xml | 56 -- .../recycleview_currency_separator_cell.xml | 21 - .../res/layout/recycleview_expense_cell.xml | 110 -- .../app/src/main/res/layout/spinner_item.xml | 23 - .../app/src/main/res/menu/menu_account.xml | 57 -- .../app/src/main/res/menu/menu_main.xml | 35 - .../src/main/res/menu/menu_monthly_report.xml | 11 - .../app/src/main/res/values-de/strings.xml | 4 + .../app/src/main/res/values-es/strings.xml | 4 + .../app/src/main/res/values-fr/strings.xml | 4 + .../app/src/main/res/values-it/strings.xml | 4 + .../app/src/main/res/values-night/colors.xml | 1 + .../app/src/main/res/values-night/styles.xml | 23 - .../app/src/main/res/values-pt/strings.xml | 4 + .../app/src/main/res/values-ru/strings.xml | 4 + .../app/src/main/res/values/colors.xml | 7 +- .../app/src/main/res/values/dimens.xml | 31 - .../app/src/main/res/values/strings.xml | 47 +- .../app/src/main/res/values/styles.xml | 60 +- .../app/src/main/res/xml/preferences.xml | 149 --- .../app/src/main/res/xml/provider_paths.xml | 2 +- .../app/src/main/res/xml/shortcuts.xml | 4 +- Android/EasyBudget/build.gradle.kts | 7 +- 193 files changed, 11430 insertions(+), 11146 deletions(-) create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/MainActivity.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppNavHost.kt rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/{theme => compose}/AppTheme.kt (96%) create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppTopAppBar.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppWithTopAppBarScaffold.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/PushPermissionState.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/components/ExpenseEditTextField.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/components/LoadingView.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/BaseFragment.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/RecurringExpenseTypeHelper.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/SerializedExpense.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/SerializedSelectedOnlineAccount.kt rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/{BaseActivity.kt => SerializedYearMonth.kt} (52%) rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/{helper/FragmentCoroutineScopeExtension.kt => injection/CurrentDBProvider.kt} (69%) delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/DatePickerDialogFragment.kt rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/{main/createaccount/CreateAccountActivity.kt => createaccount/CreateAccountView.kt} (62%) rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/{main => }/createaccount/CreateAccountViewModel.kt (98%) delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/expenseedit/ExpenseEditActivity.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/expenseedit/ExpenseEditView.kt rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/{main/login/LoginActivity.kt => login/LoginView.kt} (62%) rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/{main => }/login/LoginViewModel.kt (74%) delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/FloatingActionButtonBehavior.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/MainActivity.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/MainView.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/AccountFragment.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/AccountViewModel.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/ExpensesRecyclerViewAdapter.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/accountselector/AccountSelectorFragment.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/loading/LoadingFragment.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/manageaccount/ManageAccountActivity.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/DBLoadingErrorView.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/ExpensesView.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/FABMenuOverlay.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/MainViewContent.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/MonthlyReportHint.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/SelectedAccountHeader.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/TopBar.kt rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/{accountselector/view/Accounts.kt => subviews/accountselector/AccountSelectorView.kt} (88%) rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/{ => subviews}/accountselector/AccountSelectorViewModel.kt (82%) rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/{account => subviews}/calendar/CalendarView.kt (71%) rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/{account => subviews}/calendar/NumberFormatter.kt (96%) rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/{account => subviews}/calendar/views/CalendarDatesView.kt (96%) rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/{account => subviews}/calendar/views/CalendarDayView.kt (97%) rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/{account => subviews}/calendar/views/CalendarHeaderView.kt (97%) create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/manageaccount/ManageAccountView.kt rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/{main => }/manageaccount/ManageAccountViewModel.kt (94%) rename Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/{main/manageaccount/view => manageaccount/subviews}/Views.kt (91%) create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/MonthlyReportView.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/MonthlyReportViewModel.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/export/MonthlyReportExportView.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/export/MonthlyReportExportViewModel.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/export/subviews/Views.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/EmptyView.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/Entries.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/Entry.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/ErrorView.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/MonthHeader.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/RecapView.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/OnboardingView.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/OnboardingViewModel.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageAccountAmount.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageCurrency.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageEnd.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPagePushNotification.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageWelcome.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/PremiumActivity.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/PremiumView.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/recurringexpenseadd/RecurringExpenseEditActivity.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/recurringexpenseadd/RecurringExpenseEditView.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/MonthlyReportFragment.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/MonthlyReportRecyclerViewAdapter.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/MonthlyReportViewModel.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/base/MonthlyReportBaseActivity.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/base/MonthlyReportBaseViewModel.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/export/ExportReportActivity.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyFragment.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyRecyclerViewAdapter.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyView.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/PreferencesFragment.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/SettingsActivity.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/SettingsView.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/SettingsViewModel.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/BackupSettingsActivity.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/BackupSettingsView.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/subviews/AuthenticatedView.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/subviews/NotAuthenticatedView.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/ErrorView.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/LowMoneyWarningAmountPicker.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/RedeemCodeDialog.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/Settings.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsButton.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsCategoryTitle.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsCheckbox.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsSwitch.kt create mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/ThemePickerDialog.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding1Fragment.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding2Fragment.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding3Fragment.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding4Fragment.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/OnboardingFragment.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/OnboardingPushPermissionFragment.kt delete mode 100644 Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/WelcomeActivity.kt create mode 100644 Android/EasyBudget/app/src/main/res/drawable-hdpi/ic_launcher.png create mode 100644 Android/EasyBudget/app/src/main/res/drawable-mdpi/ic_launcher.png create mode 100644 Android/EasyBudget/app/src/main/res/drawable-xhdpi/ic_launcher.png create mode 100644 Android/EasyBudget/app/src/main/res/drawable-xxhdpi/ic_launcher.png create mode 100644 Android/EasyBudget/app/src/main/res/drawable-xxxhdpi/ic_launcher.png delete mode 100644 Android/EasyBudget/app/src/main/res/drawable/calendar_month_switcher_button_drawable.xml delete mode 100644 Android/EasyBudget/app/src/main/res/drawable/expense_date_background_drawable.xml delete mode 100644 Android/EasyBudget/app/src/main/res/drawable/expense_date_focused.xml delete mode 100644 Android/EasyBudget/app/src/main/res/drawable/expense_date_normal.xml delete mode 100644 Android/EasyBudget/app/src/main/res/drawable/expense_interval_spinner_background.xml delete mode 100644 Android/EasyBudget/app/src/main/res/drawable/expense_interval_spinner_focused.xml delete mode 100644 Android/EasyBudget/app/src/main/res/drawable/expense_interval_spinner_normal.xml delete mode 100644 Android/EasyBudget/app/src/main/res/drawable/fab_action_label_background.xml create mode 100644 Android/EasyBudget/app/src/main/res/drawable/ic_baseline_arrow_drop_up_24.xml delete mode 100644 Android/EasyBudget/app/src/main/res/drawable/triangle_drawable.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/activity_backup_settings.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/activity_create_account.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/activity_expense_edit.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/activity_login.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/activity_main.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/activity_monthly_report.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/activity_monthly_report_export.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/activity_premium.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/activity_recurring_expense_add.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/activity_settings.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/activity_welcome.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/fragment_account.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/fragment_account_loading.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/fragment_account_selector.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/fragment_monthly_report.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/fragment_onboarding1.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/fragment_onboarding2.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/fragment_onboarding3.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/fragment_onboarding4.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/fragment_onboarding_push_permission.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/fragment_select_currency.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/recyclerview_monthly_report_expense_cell.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/recyclerview_monthly_report_header_cell.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/recycleview_currency_cell.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/recycleview_currency_separator_cell.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/recycleview_expense_cell.xml delete mode 100644 Android/EasyBudget/app/src/main/res/layout/spinner_item.xml delete mode 100644 Android/EasyBudget/app/src/main/res/menu/menu_account.xml delete mode 100644 Android/EasyBudget/app/src/main/res/menu/menu_main.xml delete mode 100644 Android/EasyBudget/app/src/main/res/menu/menu_monthly_report.xml delete mode 100644 Android/EasyBudget/app/src/main/res/values-night/styles.xml delete mode 100644 Android/EasyBudget/app/src/main/res/values/dimens.xml delete mode 100644 Android/EasyBudget/app/src/main/res/xml/preferences.xml diff --git a/Android/EasyBudget/app/build.gradle.kts b/Android/EasyBudget/app/build.gradle.kts index 2bd610c0..5bc4a101 100644 --- a/Android/EasyBudget/app/build.gradle.kts +++ b/Android/EasyBudget/app/build.gradle.kts @@ -23,6 +23,7 @@ plugins { id("io.realm.kotlin") id("kotlin-parcelize") id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") } apply { @@ -38,9 +39,9 @@ android { applicationId = "com.benoitletondor.easybudgetapp" compileSdk = 34 minSdk = 23 - targetSdk = 34 - versionCode = 139 - versionName = "3.2.5" + targetSdk = 35 + versionCode = 146 + versionName = "3.3.0" vectorDrawables.useSupportLibrary = true } @@ -94,10 +95,15 @@ android { } buildFeatures { - viewBinding = true buildConfig = true compose = true } + + kotlinOptions { + freeCompilerArgs += arrayOf( + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi" + ) + } } composeCompiler { @@ -110,20 +116,16 @@ dependencies { val realmVersion: String by rootProject.extra implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.core:core-ktx:1.13.1") - implementation("com.google.android.material:material:1.12.0") - implementation("androidx.recyclerview:recyclerview:1.3.2") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.preference:preference-ktx:1.2.1") implementation("androidx.activity:activity-ktx:1.9.0") - implementation("androidx.fragment:fragment-ktx:1.8.0") + implementation("com.google.android.material:material:1.12.0") implementation("androidx.lifecycle:lifecycle-extensions:2.2.0") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.2") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3") implementation("androidx.work:work-runtime-ktx:2.9.0") implementation("androidx.work:work-gcm:2.9.0") implementation("com.google.android.play:review-ktx:2.0.1") @@ -131,7 +133,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") - implementation(platform("com.google.firebase:firebase-bom:33.1.0")) + implementation(platform("com.google.firebase:firebase-bom:33.1.1")) implementation("com.google.firebase:firebase-messaging-ktx") implementation("com.google.firebase:firebase-storage") implementation("com.google.firebase:firebase-crashlytics") @@ -147,12 +149,14 @@ dependencies { implementation("androidx.compose.material3:material3") implementation("androidx.compose.ui:ui") implementation("androidx.activity:activity-compose:1.9.0") - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3") + implementation("androidx.navigation:navigation-compose:2.8.0-beta04") implementation("com.google.accompanist:accompanist-themeadapter-material3:0.34.0") + implementation("com.google.accompanist:accompanist-permissions:0.34.0") + implementation("androidx.hilt:hilt-navigation-compose:1.2.0") implementation("com.android.billingclient:billing-ktx:7.0.0") - implementation("me.relex:circleindicator:2.1.6@aar") implementation("com.batch.android:batch-sdk:2.0.3") implementation("com.google.dagger:hilt-android:$hiltVersion") @@ -166,7 +170,7 @@ dependencies { implementation("io.realm.kotlin:library-sync:$realmVersion") - implementation("com.kizitonwose.calendar:compose:2.5.2") + implementation("com.kizitonwose.calendar:compose:2.6.0-beta02") implementation("net.sf.biweekly:biweekly:0.6.8") implementation("net.lingala.zip4j:zip4j:2.11.5") diff --git a/Android/EasyBudget/app/src/main/AndroidManifest.xml b/Android/EasyBudget/app/src/main/AndroidManifest.xml index 82de9950..5aa8c773 100644 --- a/Android/EasyBudget/app/src/main/AndroidManifest.xml +++ b/Android/EasyBudget/app/src/main/AndroidManifest.xml @@ -40,7 +40,8 @@ android:supportsRtl="false" android:theme="@style/LoadingTheme" tools:ignore="DataExtractionRules,UnusedAttribute" - android:dataExtractionRules="@xml/data_extraction_rules"> + android:dataExtractionRules="@xml/data_extraction_rules" + android:enableOnBackInvokedCallback="true"> @@ -91,77 +93,6 @@ android:name="android.app.shortcuts" android:resource="@xml/shortcuts" /> - - - - - - - - - - - 2) { - if (!hasRatingPopupBeenShownToday()) { - val shown = RatingPopup(activity, parameters).show(false) - if (shown) { - parameters.setRatingPopupLastAutoShowTimestamp(Date().time) - } - } - } - } catch (e: Exception) { - Logger.error("Error while showing rating popup", e) - } - - } - - @OptIn(DelicateCoroutinesApi::class) - private fun showPremiumPopupIfNeeded(activity: Activity) { - GlobalScope.launch { - try { - if (activity !is MainActivity) { - return@launch - } - - if ( parameters.hasPremiumPopupBeenShow() ) { - return@launch - } - - if ( iab.isUserPremium() || iab.iabStatusFlow.value == PremiumCheckStatus.ERROR ) { - return@launch - } - - if ( !parameters.hasUserCompleteRating() ) { - return@launch - } - - val currentStep = parameters.getRatingPopupUserStep() - if (currentStep == RatingPopup.RatingPopupStep.STEP_LIKE || - currentStep == RatingPopup.RatingPopupStep.STEP_LIKE_NOT_RATED || - currentStep == RatingPopup.RatingPopupStep.STEP_LIKE_RATED) { - if ( !hasRatingPopupBeenShownToday() && shouldShowPremiumPopup() ) { - parameters.setPremiumPopupLastAutoShowTimestamp(Date().time) - - withContext(Dispatchers.Main) { - MaterialAlertDialogBuilder(activity) - .setTitle(R.string.premium_popup_become_title) - .setMessage(R.string.premium_popup_become_message) - .setPositiveButton(R.string.premium_popup_become_cta) { dialog13, _ -> - val startIntent = Intent(activity, SettingsActivity::class.java) - startIntent.putExtra(SettingsActivity.SHOW_PREMIUM_INTENT_KEY, true) - ActivityCompat.startActivity(activity, startIntent, null) - - dialog13.dismiss() - } - .setNegativeButton(R.string.premium_popup_become_not_now) { dialog12, _ -> dialog12.dismiss() } - .setNeutralButton(R.string.premium_popup_become_not_ask_again) { dialog1, _ -> - parameters.setPremiumPopupShown() - dialog1.dismiss() - } - .show() - .centerButtons() - } - } - } - } catch (e: Exception) { - Logger.error("Error while showing become premium popup", e) - } - } - - } - - /** - * Has the rating popup been shown automatically today - * - * @return true if the rating popup has been shown today, false otherwise - */ - private fun hasRatingPopupBeenShownToday(): Boolean { - val lastRatingTS = parameters.getRatingPopupLastAutoShowTimestamp() - if (lastRatingTS > 0) { - val cal = Calendar.getInstance() - val currentDay = cal.get(Calendar.DAY_OF_YEAR) - - cal.time = Date(lastRatingTS) - val lastTimeDay = cal.get(Calendar.DAY_OF_YEAR) - - return currentDay == lastTimeDay - } - - return false - } - - /** - * Check that last time the premium popup was shown was 2 days ago or more - * - * @return true if we can show premium popup, false otherwise - */ - private fun shouldShowPremiumPopup(): Boolean { - val lastPremiumTS = parameters.getPremiumPopupLastAutoShowTimestamp() - if (lastPremiumTS == 0L) { - return true - } - - // Set calendar to last time 00:00 + 2 days - val cal = Calendar.getInstance() - cal.time = Date(lastPremiumTS) - cal.set(Calendar.HOUR, 0) - cal.set(Calendar.MINUTE, 0) - cal.set(Calendar.SECOND, 0) - cal.set(Calendar.MILLISECOND, 0) - cal.add(Calendar.DAY_OF_YEAR, 2) - - return Date().after(cal.time) - } /** * Set-up Batch SDK config + lifecycle @@ -468,10 +336,8 @@ class EasyBudget : Application(), Configuration.Provider { /** * Called when the app goes foreground - * - * @param activity The activity that gone foreground */ - private fun onAppForeground(activity: Activity) { + private fun onAppForeground() { Logger.debug("onAppForeground") /* @@ -511,16 +377,6 @@ class EasyBudget : Application(), Configuration.Provider { */ parameters.setLastOpenTimestamp(Date().time) - /* - * Rating popup every day after 3 opens - */ - showRatingPopupIfNeeded(activity) - - /* - * Premium popup after rating complete - */ - showPremiumPopupIfNeeded(activity) - /* * Update iap status if needed */ diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/MainActivity.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/MainActivity.kt new file mode 100644 index 00000000..fd5ff7e6 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/MainActivity.kt @@ -0,0 +1,301 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.benoitletondor.easybudgetapp + +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.benoitletondor.easybudgetapp.compose.AppNavHost +import com.benoitletondor.easybudgetapp.compose.AppTheme +import com.benoitletondor.easybudgetapp.helper.Logger +import com.benoitletondor.easybudgetapp.helper.MutableLiveFlow +import com.benoitletondor.easybudgetapp.helper.centerButtons +import com.benoitletondor.easybudgetapp.iab.Iab +import com.benoitletondor.easybudgetapp.iab.PremiumCheckStatus +import com.benoitletondor.easybudgetapp.parameters.Parameters +import com.benoitletondor.easybudgetapp.parameters.getNumberOfDailyOpen +import com.benoitletondor.easybudgetapp.parameters.getPremiumPopupLastAutoShowTimestamp +import com.benoitletondor.easybudgetapp.parameters.getRatingPopupLastAutoShowTimestamp +import com.benoitletondor.easybudgetapp.parameters.hasPremiumPopupBeenShow +import com.benoitletondor.easybudgetapp.parameters.hasUserCompleteRating +import com.benoitletondor.easybudgetapp.parameters.setPremiumPopupLastAutoShowTimestamp +import com.benoitletondor.easybudgetapp.parameters.setPremiumPopupShown +import com.benoitletondor.easybudgetapp.parameters.setRatingPopupLastAutoShowTimestamp +import com.benoitletondor.easybudgetapp.view.RatingPopup +import com.benoitletondor.easybudgetapp.view.getRatingPopupUserStep +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Calendar +import java.util.Date +import javax.inject.Inject + +/** + * Main activity of the app + * + * @author Benoit LETONDOR + */ +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + @Inject lateinit var parameters: Parameters + @Inject lateinit var iab: Iab + + private val openSubscriptionScreenLiveFlow = MutableLiveFlow() + private val openAddExpenseScreenLiveFlow = MutableLiveFlow() + private val openAddRecurringExpenseScreenLiveFlow = MutableLiveFlow() + private val openMonthlyReportScreenLiveFlow = MutableLiveFlow() + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(R.style.AppTheme) + super.onCreate(savedInstanceState) + + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark( + scrim = Color.TRANSPARENT, + ) + ) + + setContent { + AppTheme { + AppNavHost( + closeApp = { + finish() + }, + openSubscriptionScreenFlow = openSubscriptionScreenLiveFlow, + openAddExpenseScreenLiveFlow = openAddExpenseScreenLiveFlow, + openAddRecurringExpenseScreenLiveFlow = openAddRecurringExpenseScreenLiveFlow, + openMonthlyReportScreenFlow = openMonthlyReportScreenLiveFlow, + ) + } + } + } + + override fun onResume() { + super.onResume() + + showPremiumPopupIfNeeded() + showRatingPopupIfNeeded() + + performIntentActionIfAny() + } + + private fun showPremiumPopupIfNeeded() { + lifecycleScope.launch { + try { + if ( parameters.hasPremiumPopupBeenShow() ) { + return@launch + } + + if ( iab.isUserPremium() || iab.iabStatusFlow.value == PremiumCheckStatus.ERROR ) { + return@launch + } + + if ( !parameters.hasUserCompleteRating() ) { + return@launch + } + + val currentStep = parameters.getRatingPopupUserStep() + if (currentStep == RatingPopup.RatingPopupStep.STEP_LIKE || + currentStep == RatingPopup.RatingPopupStep.STEP_LIKE_NOT_RATED || + currentStep == RatingPopup.RatingPopupStep.STEP_LIKE_RATED) { + if ( !hasRatingPopupBeenShownToday() && shouldShowPremiumPopup() ) { + parameters.setPremiumPopupLastAutoShowTimestamp(Date().time) + + withContext(Dispatchers.Main) { + MaterialAlertDialogBuilder(this@MainActivity) + .setTitle(R.string.premium_popup_become_title) + .setMessage(R.string.premium_popup_become_message) + .setPositiveButton(R.string.premium_popup_become_cta) { dialog13, _ -> + lifecycleScope.launch { + openSubscriptionScreenLiveFlow.emit(Unit) + } + + dialog13.dismiss() + } + .setNegativeButton(R.string.premium_popup_become_not_now) { dialog12, _ -> dialog12.dismiss() } + .setNeutralButton(R.string.premium_popup_become_not_ask_again) { dialog1, _ -> + parameters.setPremiumPopupShown() + dialog1.dismiss() + } + .show() + .centerButtons() + } + } + } + } catch (e: Exception) { + Logger.error("Error while showing become premium popup", e) + } + } + } + + /** + * Show the rating popup if the user didn't asked not to every day after the app has been open + * in 3 different days. + */ + private fun showRatingPopupIfNeeded() { + try { + val dailyOpens = parameters.getNumberOfDailyOpen() + if (dailyOpens > 2) { + if (!hasRatingPopupBeenShownToday()) { + val shown = RatingPopup(this, parameters).show(false) + if (shown) { + parameters.setRatingPopupLastAutoShowTimestamp(Date().time) + } + } + } + } catch (e: Exception) { + Logger.error("Error while showing rating popup", e) + } + + } + + /** + * Has the rating popup been shown automatically today + * + * @return true if the rating popup has been shown today, false otherwise + */ + private fun hasRatingPopupBeenShownToday(): Boolean { + val lastRatingTS = parameters.getRatingPopupLastAutoShowTimestamp() + if (lastRatingTS > 0) { + val cal = Calendar.getInstance() + val currentDay = cal.get(Calendar.DAY_OF_YEAR) + + cal.time = Date(lastRatingTS) + val lastTimeDay = cal.get(Calendar.DAY_OF_YEAR) + + return currentDay == lastTimeDay + } + + return false + } + + /** + * Check that last time the premium popup was shown was 2 days ago or more + * + * @return true if we can show premium popup, false otherwise + */ + private fun shouldShowPremiumPopup(): Boolean { + val lastPremiumTS = parameters.getPremiumPopupLastAutoShowTimestamp() + if (lastPremiumTS == 0L) { + return true + } + + // Set calendar to last time 00:00 + 2 days + val cal = Calendar.getInstance() + cal.time = Date(lastPremiumTS) + cal.set(Calendar.HOUR, 0) + cal.set(Calendar.MINUTE, 0) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + cal.add(Calendar.DAY_OF_YEAR, 2) + + return Date().after(cal.time) + } + + private fun performIntentActionIfAny(): Boolean { + if (intent != null) { + return try { + openMonthlyReportIfNeeded(intent) || openAddExpenseIfNeeded(intent) || openAddRecurringExpenseIfNeeded(intent) + } finally { + intent = null + } + } + + return false + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + this.intent = intent + performIntentActionIfAny() + } + +// ------------------------------------------> + + /** + * Open the monthly report activity if the given intent contains the monthly uri part. + * + * @param intent + */ + private fun openMonthlyReportIfNeeded(intent: Intent): Boolean { + try { + val data = intent.data + if (data != null && "true" == data.getQueryParameter("monthly")) { + lifecycleScope.launch { + openMonthlyReportScreenLiveFlow.emit(Unit) + } + + return true + } + } catch (e: Exception) { + Logger.error("Error while opening report activity", e) + } + + return false + } + + + /** + * Open the add expense screen if the given intent contains the [.INTENT_SHOW_ADD_EXPENSE] + * extra. + * + * @param intent + */ + private fun openAddExpenseIfNeeded(intent: Intent): Boolean { + if (intent.getBooleanExtra(INTENT_SHOW_ADD_EXPENSE, false)) { + lifecycleScope.launch { + openAddExpenseScreenLiveFlow.emit(Unit) + } + + return true + } + + return false + } + + /** + * Open the add recurring expense screen if the given intent contains the [.INTENT_SHOW_ADD_RECURRING_EXPENSE] + * extra. + * + * @param intent + */ + private fun openAddRecurringExpenseIfNeeded(intent: Intent): Boolean { + if (intent.getBooleanExtra(INTENT_SHOW_ADD_RECURRING_EXPENSE, false)) { + lifecycleScope.launch { + openAddRecurringExpenseScreenLiveFlow.emit(Unit) + } + + return true + } + + return false + } + + companion object { + // Those 2 are used by the shortcuts + private const val INTENT_SHOW_ADD_EXPENSE = "intent.addexpense.show" + private const val INTENT_SHOW_ADD_RECURRING_EXPENSE = "intent.addrecurringexpense.show" + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/accounts/FirebaseAccounts.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/accounts/FirebaseAccounts.kt index d662548e..cccde20d 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/accounts/FirebaseAccounts.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/accounts/FirebaseAccounts.kt @@ -217,7 +217,7 @@ class FirebaseAccounts( val accountRef = db.collection(ACCOUNTS_COLLECTION).document(accountCredentials.id) val invitationRef = db.collection(INVITATIONS_COLLECTION).document() - val account = accountRef.get().await().toAccountOrThrow(currentUser); + val account = accountRef.get().await().toAccountOrThrow(currentUser) if (account.ownerEmail == email) { throw IllegalStateException("Cannot invite the account owner") } diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/auth/Auth.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/auth/Auth.kt index d4786d20..6f3f7c9c 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/auth/Auth.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/auth/Auth.kt @@ -16,15 +16,16 @@ package com.benoitletondor.easybudgetapp.auth -import android.app.Activity import android.content.Intent +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.result.ActivityResult import kotlinx.coroutines.flow.StateFlow interface Auth { val state: StateFlow - fun startAuthentication(activity: Activity) - fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?) + fun startAuthentication(launcher: ManagedActivityResultLauncher) + fun handleActivityResult(resultCode: Int, data: Intent?) fun logout() suspend fun refreshUserTokens() } diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/auth/FirebaseAuth.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/auth/FirebaseAuth.kt index 5cb657b1..788fef7a 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/auth/FirebaseAuth.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/auth/FirebaseAuth.kt @@ -18,6 +18,8 @@ package com.benoitletondor.easybudgetapp.auth import android.app.Activity import android.content.Intent +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.result.ActivityResult import com.benoitletondor.easybudgetapp.helper.Logger import com.firebase.ui.auth.AuthUI import com.firebase.ui.auth.IdpResponse @@ -30,8 +32,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await -private const val SIGN_IN_REQUEST_CODE = 10524 - class FirebaseAuth( private val auth: com.google.firebase.auth.FirebaseAuth, ) : Auth, CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.IO) { @@ -45,16 +45,15 @@ class FirebaseAuth( } } - override fun startAuthentication(activity: Activity) { + override fun startAuthentication(launcher: ManagedActivityResultLauncher) { currentState.value = AuthState.Authenticating try { - activity.startActivityForResult( + launcher.launch( AuthUI.getInstance() .createSignInIntentBuilder() .setAvailableProviders(listOf(AuthUI.IdpConfig.GoogleBuilder().build())) - .build(), - SIGN_IN_REQUEST_CODE + .build() ) } catch (error: Throwable) { Logger.error("FirebaseAuth", "Error launching auth activity", error) @@ -63,22 +62,19 @@ class FirebaseAuth( } - override fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == SIGN_IN_REQUEST_CODE) { - - if (resultCode != Activity.RESULT_OK) { - val response = IdpResponse.fromResultIntent(data) - if( response != null ) { - Logger.error( - "FirebaseAuth", - "Error while authenticating: ${response.error?.errorCode}: ${response.error?.localizedMessage}", - response.error - ) - } + override fun handleActivityResult(resultCode: Int, data: Intent?) { + if (resultCode != Activity.RESULT_OK) { + val response = IdpResponse.fromResultIntent(data) + if( response != null ) { + Logger.error( + "FirebaseAuth", + "Error while authenticating: ${response.error?.errorCode}: ${response.error?.localizedMessage}", + response.error + ) } - - updateAuthState() } + + updateAuthState() } override fun logout() { diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppNavHost.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppNavHost.kt new file mode 100644 index 00000000..baf1b51d --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppNavHost.kt @@ -0,0 +1,459 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.compose + +import android.os.Build +import android.os.Bundle +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import com.benoitletondor.easybudgetapp.helper.SerializedExpense +import com.benoitletondor.easybudgetapp.helper.SerializedSelectedOnlineAccount +import com.benoitletondor.easybudgetapp.helper.SerializedYearMonth +import com.benoitletondor.easybudgetapp.helper.launchCollect +import com.benoitletondor.easybudgetapp.helper.toSerializedYearMonth +import com.benoitletondor.easybudgetapp.view.createaccount.CreateAccountDestination +import com.benoitletondor.easybudgetapp.view.createaccount.CreateAccountView +import com.benoitletondor.easybudgetapp.view.expenseedit.ExpenseAddDestination +import com.benoitletondor.easybudgetapp.view.expenseedit.ExpenseEditDestination +import com.benoitletondor.easybudgetapp.view.expenseedit.ExpenseEditView +import com.benoitletondor.easybudgetapp.view.expenseedit.ExpenseEditViewModelFactory +import com.benoitletondor.easybudgetapp.view.login.LoginDestination +import com.benoitletondor.easybudgetapp.view.login.LoginView +import com.benoitletondor.easybudgetapp.view.login.LoginViewModelFactory +import com.benoitletondor.easybudgetapp.view.main.MainDestination +import com.benoitletondor.easybudgetapp.view.main.MainView +import com.benoitletondor.easybudgetapp.view.manageaccount.ManageAccountDestination +import com.benoitletondor.easybudgetapp.view.manageaccount.ManageAccountView +import com.benoitletondor.easybudgetapp.view.manageaccount.ManageAccountViewModelFactory +import com.benoitletondor.easybudgetapp.view.monthlyreport.MonthlyReportDestination +import com.benoitletondor.easybudgetapp.view.monthlyreport.MonthlyReportView +import com.benoitletondor.easybudgetapp.view.monthlyreport.MonthlyReportViewModelFactory +import com.benoitletondor.easybudgetapp.view.monthlyreport.export.MonthlyReportExportDestination +import com.benoitletondor.easybudgetapp.view.monthlyreport.export.MonthlyReportExportView +import com.benoitletondor.easybudgetapp.view.monthlyreport.export.MonthlyReportExportViewModelFactory +import com.benoitletondor.easybudgetapp.view.onboarding.OnboardingDestination +import com.benoitletondor.easybudgetapp.view.onboarding.OnboardingResult +import com.benoitletondor.easybudgetapp.view.onboarding.OnboardingView +import com.benoitletondor.easybudgetapp.view.premium.PremiumDestination +import com.benoitletondor.easybudgetapp.view.premium.PremiumView +import com.benoitletondor.easybudgetapp.view.recurringexpenseadd.RecurringExpenseAddDestination +import com.benoitletondor.easybudgetapp.view.recurringexpenseadd.RecurringExpenseEditDestination +import com.benoitletondor.easybudgetapp.view.recurringexpenseadd.RecurringExpenseEditView +import com.benoitletondor.easybudgetapp.view.recurringexpenseadd.RecurringExpenseEditViewModelFactory +import com.benoitletondor.easybudgetapp.view.settings.SettingsView +import com.benoitletondor.easybudgetapp.view.settings.SettingsViewDestination +import com.benoitletondor.easybudgetapp.view.settings.backup.BackupSettingsDestination +import com.benoitletondor.easybudgetapp.view.settings.backup.BackupSettingsView +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.time.LocalDate +import kotlin.reflect.typeOf + +private const val OnboardingResultKey = "OnboardingResult" + +@Composable +fun AppNavHost( + closeApp: () -> Unit, + openSubscriptionScreenFlow: Flow, + openAddExpenseScreenLiveFlow: Flow, + openAddRecurringExpenseScreenLiveFlow: Flow, + openMonthlyReportScreenFlow: Flow, +) { + val navController = rememberNavController() + + LaunchedEffect(key1 = "openSubscriptionScreenListener") { + launchCollect(openSubscriptionScreenFlow) { + navController.navigate(PremiumDestination(startOnPro = false)) + } + } + + NavHost( + modifier = Modifier.fillMaxSize(), + navController = navController, + startDestination = MainDestination, + enterTransition = { slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + )}, + exitTransition = { slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + )}, + popEnterTransition = { slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + )}, + popExitTransition = { slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + )}, + ) { + composable( + enterTransition = null, + ) { navBackStackEntry -> + val onboardingResultFlow = remember(navBackStackEntry) { + navBackStackEntry.savedStateHandle.getStateFlow(OnboardingResultKey, null) + .filterNotNull() + .onEach { + navBackStackEntry.savedStateHandle.remove(OnboardingResultKey) + } + } + + MainView( + openAddExpenseScreenLiveFlow = openAddExpenseScreenLiveFlow, + openAddRecurringExpenseScreenLiveFlow = openAddRecurringExpenseScreenLiveFlow, + openMonthlyReportScreenFromNotificationFlow = openMonthlyReportScreenFlow, + navigateToOnboarding = { + navController.navigate(OnboardingDestination) + }, + onboardingResultFlow = onboardingResultFlow, + closeApp = closeApp, + navigateToPremium = { startOnPro -> + navController.navigate(PremiumDestination(startOnPro = startOnPro)) + }, + navigateToMonthlyReport = { fromNotification -> + navController.navigate(MonthlyReportDestination(fromNotification = fromNotification)) + }, + navigateToManageAccount = { account -> + navController.navigate(ManageAccountDestination(selectedAccount = SerializedSelectedOnlineAccount(account))) + }, + navigateToSettings = { + navController.navigate(SettingsViewDestination) + }, + navigateToLogin = { shouldDismissAfterAuth -> + navController.navigate(LoginDestination(shouldDismissAfterAuth = shouldDismissAfterAuth)) + }, + navigateToCreateAccount = { + navController.navigate(CreateAccountDestination) + }, + navigateToAddExpense = { date, editedExpense -> + if (editedExpense != null) { + navController.navigate(ExpenseEditDestination(date = date, editedExpense = editedExpense)) + } else { + navController.navigate(ExpenseAddDestination(date = date)) + } + }, + navigateToAddRecurringExpense = { date, editedExpense -> + if (editedExpense != null) { + navController.navigate(RecurringExpenseEditDestination(date = date, editedExpense = editedExpense)) + } else { + navController.navigate(RecurringExpenseAddDestination(date = date)) + } + }, + ) + } + composable( + popEnterTransition = null, + enterTransition = null, + popExitTransition = null, + ) { + OnboardingView( + finishWithResult = { result -> + navController.previousBackStackEntry?.savedStateHandle?.set(OnboardingResultKey, result) + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val destination: PremiumDestination = backStackEntry.toRoute() + PremiumView( + startOnPro = destination.startOnPro, + close = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val destination: MonthlyReportDestination = backStackEntry.toRoute() + MonthlyReportView( + viewModel = hiltViewModel( + creationCallback = { factory: MonthlyReportViewModelFactory -> + factory.create( + fromNotification = destination.fromNotification, + ) + } + ), + navigateUp = { + navController.navigateUp() + }, + navigateToExportToCsv = { month -> + navController.navigate(MonthlyReportExportDestination(month = month.toSerializedYearMonth())) + } + ) + } + composable( + typeMap = mapOf(typeOf() to OnlineAccountNavType), + ) { backStackEntry -> + val destination: ManageAccountDestination = backStackEntry.toRoute() + ManageAccountView( + viewModel = hiltViewModel( + creationCallback = { factory: ManageAccountViewModelFactory -> + factory.create( + selectedAccount = destination.selectedAccount.toSelectedAccount(), + ) + } + ), + navigateUp = { + navController.navigateUp() + }, + finish = { + navController.popBackStack() + }, + ) + } + composable( + typeMap = mapOf(typeOf() to SerializedYearMonthNavType), + ){ backStackEntry -> + val destination: MonthlyReportExportDestination = backStackEntry.toRoute() + MonthlyReportExportView( + viewModel = hiltViewModel( + creationCallback = { factory: MonthlyReportExportViewModelFactory -> + factory.create( + month = destination.month.toYearMonth(), + ) + } + ), + navigateUp = { + navController.navigateUp() + }, + finish = { + navController.popBackStack() + }, + ) + } + composable { + SettingsView( + navigateUp = { + navController.navigateUp() + }, + navigateToBackupSettings = { + navController.navigate(BackupSettingsDestination) + }, + navigateToPremium = { + navController.navigate(PremiumDestination(startOnPro = false)) + } + ) + } + composable { + BackupSettingsView( + navigateUp = { + navController.navigateUp() + } + ) + } + composable { backStackEntry -> + val destination: LoginDestination = backStackEntry.toRoute() + LoginView( + viewModel = hiltViewModel( + creationCallback = { factory: LoginViewModelFactory -> + factory.create( + shouldDismissAfterAuth = destination.shouldDismissAfterAuth, + ) + } + ), + navigateUp = { + navController.navigateUp() + }, + finish = { + navController.popBackStack() + }, + ) + } + composable { + CreateAccountView( + navigateUp = { + navController.navigateUp() + }, + finish = { + navController.popBackStack() + }, + ) + } + composable( + typeMap = mapOf(typeOf() to SerializedExpenseNavType), + ) { backStackEntry -> + val destination: ExpenseEditDestination = backStackEntry.toRoute() + ExpenseEditView( + viewModel = hiltViewModel( + creationCallback = { factory: ExpenseEditViewModelFactory -> + factory.create( + date = LocalDate.ofEpochDay(destination.dateEpochDay), + editedExpense = destination.editedExpense.toExpense(), + ) + } + + ), + navigateUp = { + navController.navigateUp() + }, + finish = { + navController.popBackStack() + }, + ) + } + composable { backStackEntry -> + val destination: ExpenseAddDestination = backStackEntry.toRoute() + ExpenseEditView( + viewModel = hiltViewModel( + creationCallback = { factory: ExpenseEditViewModelFactory -> + factory.create( + date = LocalDate.ofEpochDay(destination.dateEpochDay), + editedExpense = null, + ) + } + + ), + navigateUp = { + navController.navigateUp() + }, + finish = { + navController.popBackStack() + }, + ) + } + composable( + typeMap = mapOf(typeOf() to SerializedExpenseNavType), + ) { backStackEntry -> + val destination: RecurringExpenseEditDestination = backStackEntry.toRoute() + RecurringExpenseEditView( + viewModel = hiltViewModel( + creationCallback = { factory: RecurringExpenseEditViewModelFactory -> + factory.create( + date = LocalDate.ofEpochDay(destination.dateEpochDay), + editedExpense = destination.editedExpense.toExpense(), + ) + } + + ), + navigateUp = { + navController.navigateUp() + }, + finish = { + navController.popBackStack() + }, + ) + } + composable { backStackEntry -> + val destination: RecurringExpenseAddDestination = backStackEntry.toRoute() + RecurringExpenseEditView( + viewModel = hiltViewModel( + creationCallback = { factory: RecurringExpenseEditViewModelFactory -> + factory.create( + date = LocalDate.ofEpochDay(destination.dateEpochDay), + editedExpense = null, + ) + } + + ), + navigateUp = { + navController.navigateUp() + }, + finish = { + navController.popBackStack() + }, + ) + } + } +} + +private val OnlineAccountNavType = object : NavType( + isNullableAllowed = false +) { + override fun get(bundle: Bundle, key: String): SerializedSelectedOnlineAccount? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + bundle.getParcelable(key, SerializedSelectedOnlineAccount::class.java) + } else { + @Suppress("DEPRECATION") + bundle.getParcelable(key) + } + } + + override fun parseValue(value: String): SerializedSelectedOnlineAccount { + return Json.decodeFromString(value) + } + + override fun serializeAsValue(value: SerializedSelectedOnlineAccount): String { + return Json.encodeToString(value) + } + + override fun put(bundle: Bundle, key: String, value: SerializedSelectedOnlineAccount) { + bundle.putParcelable(key, value) + } +} + +private val SerializedYearMonthNavType = object : NavType( + isNullableAllowed = false +) { + override fun get(bundle: Bundle, key: String): SerializedYearMonth? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + bundle.getParcelable(key, SerializedYearMonth::class.java) + } else { + @Suppress("DEPRECATION") + bundle.getParcelable(key) as? SerializedYearMonth + } + } + + override fun parseValue(value: String): SerializedYearMonth { + val json = Json.parseToJsonElement(value) + return SerializedYearMonth(json.jsonObject["year"]!!.jsonPrimitive.int, json.jsonObject["month"]!!.jsonPrimitive.int) + } + + override fun serializeAsValue(value: SerializedYearMonth): String { + return Json.encodeToJsonElement(mapOf("year" to value.year, "month" to value.month)).toString() + } + + override fun put(bundle: Bundle, key: String, value: SerializedYearMonth) { + bundle.putParcelable(key, value) + } +} + +private val SerializedExpenseNavType = object : NavType( + isNullableAllowed = false, +) { + override fun get(bundle: Bundle, key: String): SerializedExpense? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + bundle.getParcelable(key, SerializedExpense::class.java) + } else { + @Suppress("DEPRECATION") + bundle.getParcelable(key) as? SerializedExpense + } + } + + override fun parseValue(value: String): SerializedExpense { + return Json.decodeFromString(value) as SerializedExpense + } + + override fun serializeAsValue(value: SerializedExpense): String { + return Json.encodeToString(value) + } + + override fun put(bundle: Bundle, key: String, value: SerializedExpense) { + bundle.putParcelable(key, value) + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/theme/AppTheme.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppTheme.kt similarity index 96% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/theme/AppTheme.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppTheme.kt index 5badf625..b21d08a2 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/theme/AppTheme.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppTheme.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.benoitletondor.easybudgetapp.theme +package com.benoitletondor.easybudgetapp.compose import androidx.appcompat.view.ContextThemeWrapper import androidx.compose.runtime.Composable diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppTopAppBar.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppTopAppBar.kt new file mode 100644 index 00000000..744ae2bc --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppTopAppBar.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.compose + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import com.benoitletondor.easybudgetapp.R + +sealed class BackButtonBehavior { + data object Hidden : BackButtonBehavior() + @Immutable + data class NavigateBack(val onBackButtonPressed: () -> Unit) : BackButtonBehavior() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppTopAppBar( + title: String, + backButtonBehavior: BackButtonBehavior, + actions: @Composable RowScope.() -> Unit = {}, +) { + TopAppBar( + title = { + Text( + text = title, + ) + }, + actions = actions, + navigationIcon = { + if (backButtonBehavior is BackButtonBehavior.NavigateBack) { + IconButton(onClick = backButtonBehavior.onBackButtonPressed) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Up button", + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = colorResource(id = R.color.action_bar_background), + titleContentColor = colorResource(id = R.color.action_bar_text_color), + actionIconContentColor = colorResource(id = R.color.action_bar_text_color), + navigationIconContentColor = colorResource(id = R.color.action_bar_text_color), + ), + ) +} + +@Composable +fun AppTopBarMoreMenuItem(content: @Composable ColumnScope.(dismiss: () -> Unit) -> Unit) { + var showMenu by remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .clip(CircleShape) + .clickable { showMenu = true } + .padding(all = 8.dp), + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = "Menu", + ) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + content { showMenu = false } + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppWithTopAppBarScaffold.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppWithTopAppBarScaffold.kt new file mode 100644 index 00000000..04d81dff --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/AppWithTopAppBarScaffold.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.compose + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable + +@Composable +fun AppWithTopAppBarScaffold( + title: String, + backButtonBehavior: BackButtonBehavior, + actions: @Composable RowScope.() -> Unit = {}, + content: @Composable (PaddingValues) -> Unit, +) { + Scaffold( + topBar = { + AppTopAppBar( + title = title, + backButtonBehavior = backButtonBehavior, + actions = actions, + ) + }, + content = content, + ) +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/PushPermissionState.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/PushPermissionState.kt new file mode 100644 index 00000000..781d026d --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/PushPermissionState.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.compose + +import android.Manifest +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState + +private val isAndroid33OrMore = Build.VERSION.SDK_INT >= 33 + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun rememberPermissionStateCompat( + onPermissionResult: (Boolean) -> Unit, +) : PermissionState { + return if (isAndroid33OrMore) { + rememberPermissionState( + Manifest.permission.POST_NOTIFICATIONS, + onPermissionResult = onPermissionResult, + ) + } else { + remember { + object : PermissionState { + override val permission: String = "android.permission.POST_NOTIFICATIONS" + override val status: PermissionStatus = PermissionStatus.Granted + override fun launchPermissionRequest() { + onPermissionResult(true) + } + } + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/components/ExpenseEditTextField.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/components/ExpenseEditTextField.kt new file mode 100644 index 00000000..824543f2 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/components/ExpenseEditTextField.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.compose.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExpenseEditTextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = TextStyle( + color = Color.White, + fontSize = 17.sp, + ), + label: String, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = true, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors( + focusedContainerColor = colorResource(R.color.action_bar_background), + unfocusedContainerColor = colorResource(R.color.action_bar_background), + errorContainerColor = colorResource(R.color.action_bar_background), + cursorColor = Color.White, + focusedLabelColor = Color.White, + unfocusedLabelColor = Color.White, + errorLabelColor = colorResource(R.color.budget_red), + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + errorTextColor = Color.White, + focusedIndicatorColor = colorResource(R.color.expense_edit_field_accent_color_dark), + unfocusedIndicatorColor = colorResource(R.color.expense_edit_field_accent_color_dark), + errorIndicatorColor = colorResource(R.color.expense_edit_field_accent_color_dark), + ), +) { + val textColor = textStyle.color + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + + val customTextSelectionColors = TextSelectionColors( + handleColor = Color.White, + backgroundColor = Color.White.copy(alpha = 0.4f) + ) + + CompositionLocalProvider(LocalTextSelectionColors provides customTextSelectionColors) { + BasicTextField( + value = value, + modifier = modifier + .defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = TextFieldDefaults.MinHeight + 4.dp, + ), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, + cursorBrush = SolidColor(Color.White), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = @Composable { innerTextField -> + // places leading icon, text field with label and placeholder, trailing icon + TextFieldDefaults.DecorationBox( + value = value.text, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = { + Text( + modifier = Modifier.padding(bottom = 4.dp), + text = label, + fontSize = 15.sp, + ) + }, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + shape = shape, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + contentPadding = PaddingValues(top = 7.dp), + ) + } + ) + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/components/LoadingView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/components/LoadingView.kt new file mode 100644 index 00000000..71eb66a5 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/compose/components/LoadingView.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.compose.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun LoadingView( + modifier: Modifier = Modifier, + loadingText: String? = null, +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + ) { + CircularProgressIndicator() + + if (loadingText != null) { + Spacer(modifier = Modifier.height(10.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = loadingText, + ) + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/DB.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/DB.kt index 8361d694..1a3dd25f 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/DB.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/DB.kt @@ -30,6 +30,8 @@ interface DB { suspend fun triggerForceWriteToDisk() + suspend fun forceCacheWipe() + suspend fun persistExpense(expense: Expense): Expense suspend fun getDataForMonth(yearMonth: YearMonth): DataForMonth diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/cacheimpl/CachedDBImpl.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/cacheimpl/CachedDBImpl.kt index a429ce5a..c5c74c97 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/cacheimpl/CachedDBImpl.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/cacheimpl/CachedDBImpl.kt @@ -62,6 +62,10 @@ open class CachedDBImpl( wrappedDB.triggerForceWriteToDisk() } + override suspend fun forceCacheWipe() { + wipeCache() + } + override suspend fun persistExpense(expense: Expense): Expense = wrappedDB.persistExpense(expense) diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/offlineimpl/OfflineDBImpl.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/offlineimpl/OfflineDBImpl.kt index fdeae333..1927ba9f 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/offlineimpl/OfflineDBImpl.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/offlineimpl/OfflineDBImpl.kt @@ -49,6 +49,10 @@ class OfflineDBImpl(private val roomDB: RoomDB) : DB { roomDB.expenseDao().checkpoint(SimpleSQLiteQuery("pragma wal_checkpoint(full)")) } + override suspend fun forceCacheWipe() { + /* No-op as this is a non-cached implementation */ + } + override suspend fun persistExpense(expense: Expense): Expense { val newId = roomDB.expenseDao().persistExpense(expense.toExpenseEntity()) @@ -58,8 +62,8 @@ class OfflineDBImpl(private val roomDB: RoomDB) : DB { } override suspend fun getDataForMonth(yearMonth: YearMonth): DataForMonth { - val startDate = yearMonth.atStartOfMonth().minusDays(DataForMonth.numberOfLeewayDays) - val endDate = yearMonth.atEndOfMonth().plusDays(DataForMonth.numberOfLeewayDays) + val startDate = yearMonth.atStartOfMonth().minusDays(DataForMonth.NUMBER_OF_LEEWAY_DAYS) + val endDate = yearMonth.atEndOfMonth().plusDays(DataForMonth.NUMBER_OF_LEEWAY_DAYS) var balance = roomDB.expenseDao().getBalanceForDay(startDate.minusDays(1)).getRealValueFromDB() var checkedBalance = roomDB.expenseDao().getCheckedBalanceForDay(startDate.minusDays(1)).getRealValueFromDB() diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/onlineimpl/OnlineDBImpl.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/onlineimpl/OnlineDBImpl.kt index 90187a3c..e34309d5 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/onlineimpl/OnlineDBImpl.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/db/onlineimpl/OnlineDBImpl.kt @@ -16,7 +16,6 @@ package com.benoitletondor.easybudgetapp.db.onlineimpl -import com.benoitletondor.easybudgetapp.BuildConfig import com.benoitletondor.easybudgetapp.auth.CurrentUser import com.benoitletondor.easybudgetapp.db.RestoreAction import com.benoitletondor.easybudgetapp.db.onlineimpl.entity.ExpenseEntity @@ -31,13 +30,10 @@ import com.kizitonwose.calendar.core.atStartOfMonth import io.realm.kotlin.Realm import io.realm.kotlin.UpdatePolicy import io.realm.kotlin.ext.query -import io.realm.kotlin.internal.RealmImpl import io.realm.kotlin.mongodb.App -import io.realm.kotlin.mongodb.AppConfiguration import io.realm.kotlin.mongodb.Credentials import io.realm.kotlin.mongodb.subscriptions import io.realm.kotlin.mongodb.sync.SyncConfiguration -import io.realm.kotlin.mongodb.syncSession import io.realm.kotlin.notifications.UpdatedRealm import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -87,6 +83,10 @@ class OnlineDBImpl( override suspend fun triggerForceWriteToDisk() { /* No-op */ } + override suspend fun forceCacheWipe() { + /* No-op as this is a non-cached implementation */ + } + suspend fun awaitSyncDone(): SyncSessionState { if (syncSessionState.value is SyncSessionState.Done) { return SyncSessionState.Done @@ -190,8 +190,8 @@ class OnlineDBImpl( } override suspend fun getDataForMonth(yearMonth: YearMonth): DataForMonth { - val startDate = yearMonth.atStartOfMonth().minusDays(DataForMonth.numberOfLeewayDays) - val endDate = yearMonth.atEndOfMonth().plusDays(DataForMonth.numberOfLeewayDays) + val startDate = yearMonth.atStartOfMonth().minusDays(DataForMonth.NUMBER_OF_LEEWAY_DAYS) + val endDate = yearMonth.atEndOfMonth().plusDays(DataForMonth.NUMBER_OF_LEEWAY_DAYS) var balance = getBalanceForDay(startDate.minusDays(1)) var checkedBalance = getCheckedBalanceForDay(startDate.minusDays(1)) diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/BaseFragment.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/BaseFragment.kt deleted file mode 100644 index 88f83c3e..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/BaseFragment.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.benoitletondor.easybudgetapp.helper - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.CallSuper -import androidx.fragment.app.Fragment -import androidx.viewbinding.ViewBinding - -abstract class BaseFragment : Fragment() { - protected var binding: V? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val binding = onCreateBinding(inflater, container, savedInstanceState) - this.binding = binding - return binding.root - } - - @CallSuper - override fun onDestroyView() { - super.onDestroyView() - binding = null - } - - abstract fun onCreateBinding(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): V -} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/CurrencyHelper.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/CurrencyHelper.kt index 1cc8e932..f2b6ff47 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/CurrencyHelper.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/CurrencyHelper.kt @@ -17,6 +17,8 @@ package com.benoitletondor.easybudgetapp.helper import com.benoitletondor.easybudgetapp.parameters.Parameters +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import java.text.NumberFormat import java.util.* @@ -73,21 +75,25 @@ object CurrencyHelper { fun getCurrencyDisplayName(currency: Currency): String = currency.symbol + " - " + currency.displayName - /** - * Helper to display an amount using the user currency - */ - fun getFormattedCurrencyString(parameters: Parameters, amount: Double): String { + fun getFormattedCurrencyString(currency: Currency, amount: Double): String { val currencyFormat = NumberFormat.getCurrencyInstance() // No fraction digits currencyFormat.maximumFractionDigits = 2 currencyFormat.minimumFractionDigits = 2 - currencyFormat.currency = parameters.getUserCurrency() + currencyFormat.currency = currency return currencyFormat.format(amount) } + /** + * Helper to display an amount using the user currency + */ + fun getFormattedCurrencyString(parameters: Parameters, amount: Double): String { + return getFormattedCurrencyString(parameters.getUserCurrency(), amount) + } + /** * Helper to display an amount into an edit text */ @@ -107,9 +113,24 @@ object CurrencyHelper { */ private const val CURRENCY_ISO_PARAMETERS_KEY = "currency_iso" +private lateinit var userCurrencyFlow: MutableStateFlow + +fun Parameters.watchUserCurrency(): StateFlow { + if (!::userCurrencyFlow.isInitialized) { + userCurrencyFlow = MutableStateFlow(getUserCurrency()) + } + + return userCurrencyFlow +} + fun Parameters.getUserCurrency(): Currency = Currency.getInstance(getString(CURRENCY_ISO_PARAMETERS_KEY)) fun Parameters.setUserCurrency(currency: Currency) { + if (!::userCurrencyFlow.isInitialized) { + userCurrencyFlow = MutableStateFlow(currency) + } + + userCurrencyFlow.value = currency putString(CURRENCY_ISO_PARAMETERS_KEY, currency.currencyCode) } diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/FlowCollectExtension.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/FlowCollectExtension.kt index f2cd444d..9f0c5389 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/FlowCollectExtension.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/FlowCollectExtension.kt @@ -35,6 +35,48 @@ fun CoroutineScope.launchCollect( } } +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6) -> R, +): Flow = kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + ) +} + +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R, +): Flow = kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + ) +} + @Suppress("UNCHECKED_CAST") fun combine( flow: Flow, diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/Logger.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/Logger.kt index b1c292bd..0ad29baf 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/Logger.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/Logger.kt @@ -20,14 +20,14 @@ import com.benoitletondor.easybudgetapp.BuildConfig import com.google.firebase.crashlytics.FirebaseCrashlytics object Logger { - private const val defaultTag = "EasyBudget" + private const val DEFAULT_TAG = "EasyBudget" fun debug(message: String) { debug(message, error = null) } fun debug(message: String, error: Throwable?) { - debug(defaultTag, message, error) + debug(DEFAULT_TAG, message, error) } fun debug(tag: String, message: String) { @@ -53,7 +53,7 @@ object Logger { } fun warning(message: String, error: Throwable?) { - warning(defaultTag, message, error) + warning(DEFAULT_TAG, message, error) } fun warning(tag: String, message: String) { @@ -77,7 +77,7 @@ object Logger { } fun error(message: String, error: Throwable?) { - error(defaultTag, message, error) + error(DEFAULT_TAG, message, error) } fun error(tag: String, message: String) { diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/RecurringExpenseTypeHelper.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/RecurringExpenseTypeHelper.kt new file mode 100644 index 00000000..76e9163c --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/RecurringExpenseTypeHelper.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.helper + +import android.content.Context +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.model.RecurringExpenseType + +fun RecurringExpenseType.stringRepresentation(context: Context): String { + return when (this) { + RecurringExpenseType.DAILY -> context.getString(R.string.recurring_interval_daily) + RecurringExpenseType.WEEKLY -> context.getString(R.string.recurring_interval_weekly) + RecurringExpenseType.BI_WEEKLY -> context.getString(R.string.recurring_interval_bi_weekly) + RecurringExpenseType.TER_WEEKLY -> context.getString(R.string.recurring_interval_ter_weekly) + RecurringExpenseType.FOUR_WEEKLY -> context.getString(R.string.recurring_interval_four_weekly) + RecurringExpenseType.MONTHLY -> context.getString(R.string.recurring_interval_monthly) + RecurringExpenseType.BI_MONTHLY -> context.getString(R.string.recurring_interval_bi_monthly) + RecurringExpenseType.TER_MONTHLY -> context.getString(R.string.recurring_interval_ter_monthly) + RecurringExpenseType.SIX_MONTHLY -> context.getString(R.string.recurring_interval_six_monthly) + RecurringExpenseType.YEARLY -> context.getString(R.string.recurring_interval_yearly) + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/SerializedExpense.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/SerializedExpense.kt new file mode 100644 index 00000000..1a325520 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/SerializedExpense.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.helper + +import android.os.Parcelable +import com.benoitletondor.easybudgetapp.model.AssociatedRecurringExpense +import com.benoitletondor.easybudgetapp.model.Expense +import com.benoitletondor.easybudgetapp.model.RecurringExpense +import com.benoitletondor.easybudgetapp.model.RecurringExpenseType +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import java.net.URLDecoder +import java.net.URLEncoder +import java.time.LocalDate + +@Serializable +@Parcelize +data class SerializedExpense( + val id: Long?, + val title: String, + val amount: Double, + val date: Long, + val checked: Boolean, + val associatedRecurringExpense: SerializedAssociatedRecurringExpense? +) : Parcelable { + constructor(expense: Expense) : this( + expense.id, + URLEncoder.encode(expense.title, "UTF-8"), + expense.amount, + expense.date.toEpochDay(), + expense.checked, + expense.associatedRecurringExpense?.let { SerializedAssociatedRecurringExpense(it) }, + ) + + fun toExpense(): Expense = Expense( + id, + URLDecoder.decode(title, "UTF-8"), + amount, + LocalDate.ofEpochDay(date), + checked, + associatedRecurringExpense?.toAssociatedRecurringExpense(), + ) +} + +@Serializable +@Parcelize +data class SerializedAssociatedRecurringExpense( + val recurringExpense: SerializedRecurringExpense, + val originalDate: Long, +) : Parcelable { + constructor(associatedRecurringExpense: AssociatedRecurringExpense) : this( + SerializedRecurringExpense(associatedRecurringExpense.recurringExpense), + associatedRecurringExpense.originalDate.toEpochDay(), + ) + + fun toAssociatedRecurringExpense() = AssociatedRecurringExpense( + recurringExpense.toRecurringExpense(), + LocalDate.ofEpochDay(originalDate), + ) +} + +@Serializable +@Parcelize +data class SerializedRecurringExpense( + val id: Long?, + val title: String, + val amount: Double, + val recurringDate: Long, + val modified: Boolean, + val type: RecurringExpenseType +) : Parcelable { + constructor(recurringExpense: RecurringExpense) : this( + recurringExpense.id, + URLEncoder.encode(recurringExpense.title, "UTF-8"), + recurringExpense.amount, + recurringExpense.recurringDate.toEpochDay(), + recurringExpense.modified, + recurringExpense.type, + ) + + fun toRecurringExpense() = RecurringExpense( + id, + URLDecoder.decode(title, "UTF-8"), + amount, + LocalDate.ofEpochDay(recurringDate), + modified, + type, + ) +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/SerializedSelectedOnlineAccount.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/SerializedSelectedOnlineAccount.kt new file mode 100644 index 00000000..5372f1fd --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/SerializedSelectedOnlineAccount.kt @@ -0,0 +1,34 @@ +package com.benoitletondor.easybudgetapp.helper + +import android.os.Parcelable +import com.benoitletondor.easybudgetapp.view.main.MainViewModel +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import java.net.URLDecoder +import java.net.URLEncoder + +@Serializable +@Parcelize +data class SerializedSelectedOnlineAccount( + val name: String, + val isOwner: Boolean, + val ownerEmail: String, + val accountId: String, + val accountSecret: String, +) : Parcelable { + constructor(selectedAccount: MainViewModel.SelectedAccount.Selected.Online) : this( + URLEncoder.encode(selectedAccount.name, "UTF-8"), + selectedAccount.isOwner, + selectedAccount.ownerEmail, + selectedAccount.accountId, + selectedAccount.accountSecret, + ) + + fun toSelectedAccount() = MainViewModel.SelectedAccount.Selected.Online( + URLDecoder.decode(name, "UTF-8"), + isOwner, + ownerEmail, + accountId, + accountSecret, + ) +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/BaseActivity.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/SerializedYearMonth.kt similarity index 52% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/BaseActivity.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/SerializedYearMonth.kt index 41fd79fb..0742e77e 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/BaseActivity.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/SerializedYearMonth.kt @@ -13,28 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.benoitletondor.easybudgetapp.helper -import android.os.Bundle -import androidx.annotation.CallSuper -import androidx.appcompat.app.AppCompatActivity -import androidx.viewbinding.ViewBinding -import com.benoitletondor.easybudgetapp.R - -abstract class BaseActivity : AppCompatActivity() { - protected lateinit var binding: V - - @CallSuper - override fun onCreate(savedInstanceState: Bundle?) { - setTheme(R.style.AppTheme) +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import java.time.YearMonth - super.onCreate(savedInstanceState) +@Serializable +@Parcelize +data class SerializedYearMonth(val year: Int, val month: Int) : Parcelable { + constructor(yearMonth: YearMonth) : this(yearMonth.year, yearMonth.monthValue) - val binding = createBinding() - this.binding = binding - setContentView(binding.root) - } + fun toYearMonth(): YearMonth = YearMonth.of(year, month) +} - abstract fun createBinding(): V -} \ No newline at end of file +fun YearMonth.toSerializedYearMonth() = SerializedYearMonth(this) \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/UIHelper.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/UIHelper.kt index 8b518e83..ea476fa4 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/UIHelper.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/UIHelper.kt @@ -16,33 +16,32 @@ package com.benoitletondor.easybudgetapp.helper -import android.app.Activity import android.content.Context -import android.os.Build import android.text.Editable import android.text.TextWatcher import android.view.Gravity -import android.view.View -import android.view.WindowManager -import android.view.animation.AccelerateInterpolator -import android.view.inputmethod.InputMethodManager -import android.widget.Button import android.widget.EditText import android.widget.LinearLayout -import androidx.annotation.ColorRes import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat -import androidx.core.view.ViewCompat import androidx.core.view.updateLayoutParams import com.benoitletondor.easybudgetapp.R -import java.time.LocalDate import java.time.YearMonth import java.time.format.DateTimeFormatter import java.util.Locale /** - * This helper prevents the user to add unsupported values into an EditText for decimal numbers + * This helper prevents the user to add unsupported values into an TextField for decimal numbers */ +fun String.sanitizeFromUnsupportedInputForDecimals(supportsNegativeValue: Boolean = true): String { + val s = Editable.Factory.getInstance().newEditable( + filter { (if (supportsNegativeValue) "-0123456789.," else "0123456789.,").contains(it) } + ) + + s.sanitizeFromUnsupportedInputForDecimals() + + return s.toString() +} + fun EditText.preventUnsupportedInputForDecimals() { addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} @@ -50,108 +49,57 @@ fun EditText.preventUnsupportedInputForDecimals() { override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable) { - val value = text.toString() - - try { - // Remove - that is not at first char - val minusIndex = value.lastIndexOf("-") - if (minusIndex > 0) { - s.delete(minusIndex, minusIndex + 1) - - if (value.startsWith("-")) { - s.delete(0, 1) - } else { - s.insert(0, "-") - } - - return - } - - val comaIndex = value.indexOf(",") - val dotIndex = value.indexOf(".") - val lastDotIndex = value.lastIndexOf(".") - - // Remove , - if (comaIndex >= 0) { - if (dotIndex >= 0) { - s.delete(comaIndex, comaIndex + 1) - } else { - s.replace(comaIndex, comaIndex + 1, ".") - } - - return - } - - // Disallow double . - if (dotIndex >= 0 && dotIndex != lastDotIndex) { - s.delete(lastDotIndex, lastDotIndex + 1) - } else if (dotIndex > 0) { - val decimals = value.substring(dotIndex + 1) - if (decimals.length > 2) { - s.delete(dotIndex + 3, value.length) - } - }// No more than 2 decimals - } catch (e: Exception) { - Logger.error("An error occurred during text changing watcher. Value: $value", e) - } + s.sanitizeFromUnsupportedInputForDecimals() } }) } -/** - * Show the FAB, animating the appearance if activated (the FAB should be configured with scale & alpha to 0) - */ -fun View.animateFABAppearance() { - ViewCompat.animate(this) - .scaleX(1.0f) - .scaleY(1.0f) - .alpha(1.0f) - .setInterpolator(AccelerateInterpolator()) - .withLayer() - .start() -} - -/** - * Set the focus on the given text view - */ -fun EditText.setFocus() { - requestFocus() +private fun Editable.sanitizeFromUnsupportedInputForDecimals() { + try { + // Remove - that is not at first char + val minusIndex = lastIndexOf("-") + if (minusIndex > 0) { + delete(minusIndex, minusIndex + 1) + + if (startsWith("-")) { + delete(0, 1) + } else { + insert(0, "-") + } - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) -} + return + } -/** - * Set the status bar color - */ -fun Activity.setStatusBarColor(@ColorRes colorRes: Int) { - val window = window + val comaIndex = indexOf(",") + val dotIndex = indexOf(".") + val lastDotIndex = lastIndexOf(".") - if (window.attributes.flags and WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS == 0) { - window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) - } + // Remove , + if (comaIndex >= 0) { + if (dotIndex >= 0) { + delete(comaIndex, comaIndex + 1) + } else { + replace(comaIndex, comaIndex + 1, ".") + } - window.statusBarColor = ContextCompat.getColor(this, colorRes) + return + } - if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ) { - window.navigationBarColor = ContextCompat.getColor(this, colorRes) + // Disallow double . + if (dotIndex >= 0 && dotIndex != lastDotIndex) { + delete(lastDotIndex, lastDotIndex + 1) + } else if (dotIndex >= 0) { + // No more than 2 decimals + val decimals = substring(dotIndex + 1) + if (decimals.length > 2) { + delete(dotIndex + 3, length) + } + } + } catch (e: Exception) { + Logger.error("An error occurred during text changing watcher. Value: $this", e) } } -fun Activity.setNavigationBarColored() { - var flags = window.decorView.systemUiVisibility - flags = flags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() - - window.decorView.systemUiVisibility = flags -} - -/** - * Remove border of the button - */ -fun Button.removeButtonBorder() { - outlineProvider = null -} - /** * Center buttons of the given dialog (used to center when 3 choices are available). */ diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/iab/IabImpl.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/iab/IabImpl.kt index d63ea7f2..d6bdf445 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/iab/IabImpl.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/iab/IabImpl.kt @@ -28,7 +28,6 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.first private const val SKU_PREMIUM_LEGACY = "premium" @@ -46,7 +45,11 @@ class IabImpl( private val appContext = context.applicationContext private val billingClient = BillingClient.newBuilder(appContext) .setListener(this) - .enablePendingPurchases() + .enablePendingPurchases( + PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .build() + ) .build() /** @@ -545,6 +548,10 @@ class IabImpl( purchase.products.contains(SKU_PREMIUM_SUBSCRIPTION) || purchase.products.contains(SKU_PRO_SUBSCRIPTION)) { + if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) { + continue + } + val ackResult = billingClient.acknowledgePurchase(AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()) if( ackResult.responseCode != BillingClient.BillingResponseCode.OK ) { diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/injection/AppModule.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/injection/AppModule.kt index 9c88d0e7..60e5398a 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/injection/AppModule.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/injection/AppModule.kt @@ -49,8 +49,6 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object AppModule { - private var usedOnlineDB: CachedOnlineDBImpl? = null - @Provides @Singleton fun provideIab( @@ -65,6 +63,10 @@ object AppModule { @Singleton fun provideAccounts(): Accounts = FirebaseAccounts(Firebase.firestore) + @Provides + @Singleton + fun provideCurrentDBProvider(): CurrentDBProvider = CurrentDBProvider(activeDB = null) + @Provides @Singleton fun provideCloudStorage(): CloudStorage = FirebaseStorage(com.google.firebase.storage.FirebaseStorage.getInstance().apply { @@ -81,7 +83,8 @@ object AppModule { OfflineDBImpl(RoomDB.create(context)), ) - private var app: App? = null; + private var app: App? = null + private var usedOnlineDB: CachedOnlineDBImpl? = null suspend fun provideSyncedOnlineDBOrThrow( currentUser: CurrentUser, diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/FragmentCoroutineScopeExtension.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/injection/CurrentDBProvider.kt similarity index 69% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/FragmentCoroutineScopeExtension.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/injection/CurrentDBProvider.kt index cbd0e5bd..7becccc7 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/helper/FragmentCoroutineScopeExtension.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/injection/CurrentDBProvider.kt @@ -13,10 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.benoitletondor.easybudgetapp.helper +package com.benoitletondor.easybudgetapp.injection -import androidx.fragment.app.Fragment -import androidx.lifecycle.coroutineScope -import kotlinx.coroutines.CoroutineScope +import com.benoitletondor.easybudgetapp.db.DB -val Fragment.viewLifecycleScope: CoroutineScope get() = this.viewLifecycleOwner.lifecycle.coroutineScope \ No newline at end of file +data class CurrentDBProvider(var activeDB: DB?) +val CurrentDBProvider.requireDB: DB get() = activeDB ?: throw IllegalStateException("No DB provided") \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/AssociatedRecurringExpense.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/AssociatedRecurringExpense.kt index 17bd10ab..39b6799f 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/AssociatedRecurringExpense.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/AssociatedRecurringExpense.kt @@ -16,36 +16,14 @@ package com.benoitletondor.easybudgetapp.model -import android.os.Parcel import android.os.Parcelable import androidx.compose.runtime.Immutable +import kotlinx.parcelize.Parcelize import java.time.LocalDate @Immutable +@Parcelize data class AssociatedRecurringExpense( val recurringExpense: RecurringExpense, val originalDate: LocalDate, -) : Parcelable { - - constructor(parcel: Parcel) : this( - parcel.readParcelable(RecurringExpense::class.java.classLoader)!!, - LocalDate.ofEpochDay(parcel.readLong()), - ) - - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeParcelable(recurringExpense, flags) - parcel.writeLong(originalDate.toEpochDay()) - } - - override fun describeContents(): Int = 0 - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): AssociatedRecurringExpense { - return AssociatedRecurringExpense(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } -} +) : Parcelable diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/DataForMonth.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/DataForMonth.kt index 7c69e097..56a47ecb 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/DataForMonth.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/DataForMonth.kt @@ -26,7 +26,7 @@ data class DataForMonth( val daysData: Map, ) { companion object { - const val numberOfLeewayDays: Long = 6 + const val NUMBER_OF_LEEWAY_DAYS: Long = 6 } } diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/Expense.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/Expense.kt index 2c317c29..8af7f5eb 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/Expense.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/Expense.kt @@ -16,12 +16,13 @@ package com.benoitletondor.easybudgetapp.model -import android.os.Parcel import android.os.Parcelable import androidx.compose.runtime.Immutable +import kotlinx.parcelize.Parcelize import java.time.LocalDate @Immutable +@Parcelize data class Expense(val id: Long?, val title: String, val amount: Double, @@ -46,44 +47,7 @@ data class Expense(val id: Long?, checked: Boolean, associatedRecurringExpense: AssociatedRecurringExpense) : this(null, title, amount, date, checked, associatedRecurringExpense) - private constructor(parcel: Parcel) : this( - parcel.readValue(Long::class.java.classLoader) as? Long, - parcel.readString()!!, - parcel.readDouble(), - LocalDate.ofEpochDay(parcel.readLong()), - parcel.readInt() == 1, - parcel.readParcelable(AssociatedRecurringExpense::class.java.classLoader) - ) - - init { - if( title.isEmpty() ) { - throw IllegalArgumentException("title is empty") - } - } - fun isRevenue() = amount < 0 fun isRecurring() = associatedRecurringExpense != null - - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeValue(id) - parcel.writeString(title) - parcel.writeDouble(amount) - parcel.writeLong(date.toEpochDay()) - parcel.writeInt(if( checked ) { 1 } else { 0 }) - parcel.writeParcelable(associatedRecurringExpense, flags) - } - - override fun describeContents(): Int = 0 - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): Expense { - return Expense(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } - } \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/RecurringExpense.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/RecurringExpense.kt index 1cd33037..ad098009 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/RecurringExpense.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/RecurringExpense.kt @@ -16,51 +16,22 @@ package com.benoitletondor.easybudgetapp.model -import android.os.Parcel import android.os.Parcelable import androidx.compose.runtime.Immutable +import kotlinx.parcelize.Parcelize import java.time.LocalDate @Immutable +@Parcelize data class RecurringExpense(val id: Long?, val title: String, val amount: Double, val recurringDate: LocalDate, val modified: Boolean, val type: RecurringExpenseType) : Parcelable { - - private constructor(parcel: Parcel) : this( - parcel.readValue(Long::class.java.classLoader) as? Long, - parcel.readString()!!, - parcel.readDouble(), - LocalDate.ofEpochDay(parcel.readLong()), - parcel.readByte() != 0.toByte(), - RecurringExpenseType.entries[parcel.readInt()] - ) constructor(title: String, originalAmount: Double, recurringDate: LocalDate, type: RecurringExpenseType) : this(null, title, originalAmount, recurringDate, false, type) - - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeValue(id) - parcel.writeString(title) - parcel.writeDouble(amount) - parcel.writeLong(recurringDate.toEpochDay()) - parcel.writeByte(if (modified) 1 else 0) - parcel.writeInt(type.ordinal) - } - - override fun describeContents(): Int = 0 - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): RecurringExpense { - return RecurringExpense(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } } \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/RecurringExpenseDeleteType.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/RecurringExpenseDeleteType.kt index 122f0b45..99f930da 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/RecurringExpenseDeleteType.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/model/RecurringExpenseDeleteType.kt @@ -41,23 +41,4 @@ enum class RecurringExpenseDeleteType(val value: Int) { * Delete this expense occurrence only */ ONE(3); - - - companion object { - /** - * Retrieve the enum for the given value - * - * @param value - * @return - */ - fun fromValue(value: Int): RecurringExpenseDeleteType? { - for (type in entries) { - if (value == type.value) { - return type - } - } - - return null - } - } } \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/parameters/ParametersExtension.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/parameters/ParametersExtension.kt index 587ab79c..e13fd5f4 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/parameters/ParametersExtension.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/parameters/ParametersExtension.kt @@ -268,6 +268,16 @@ private fun Int.toDayOfWeek(): DayOfWeek { } } +private lateinit var userAllowingUpdatePushesFlow: MutableStateFlow + +fun Parameters.watchUserAllowingUpdatePushes(): StateFlow { + if (!::userAllowingUpdatePushesFlow.isInitialized) { + userAllowingUpdatePushesFlow = MutableStateFlow(isUserAllowingUpdatePushes()) + } + + return userAllowingUpdatePushesFlow +} + /** * The user wants or not to receive notification about updates * @@ -283,9 +293,24 @@ fun Parameters.isUserAllowingUpdatePushes(): Boolean { * @param value if the user wants or not to receive notifications about updates */ fun Parameters.setUserAllowUpdatePushes(value: Boolean) { + if (!::userAllowingUpdatePushesFlow.isInitialized) { + userAllowingUpdatePushesFlow = MutableStateFlow(value) + } + + userAllowingUpdatePushesFlow.value = value putBoolean(USER_ALLOW_UPDATE_PUSH_PARAMETERS_KEY, value) } +private lateinit var userAllowingDailyReminderPushesFlow: MutableStateFlow + +fun Parameters.watchUserAllowingDailyReminderPushes(): StateFlow { + if (!::userAllowingDailyReminderPushesFlow.isInitialized) { + userAllowingDailyReminderPushesFlow = MutableStateFlow(isUserAllowingDailyReminderPushes()) + } + + return userAllowingDailyReminderPushesFlow +} + /** * The user wants or not to receive a daily reminder notification * @@ -301,9 +326,24 @@ fun Parameters.isUserAllowingDailyReminderPushes(): Boolean { * @param value if the user wants or not to receive daily notifications */ fun Parameters.setUserAllowDailyReminderPushes(value: Boolean) { + if (!::userAllowingDailyReminderPushesFlow.isInitialized) { + userAllowingDailyReminderPushesFlow = MutableStateFlow(value) + } + + userAllowingDailyReminderPushesFlow.value = value putBoolean(USER_ALLOW_DAILY_PUSH_PARAMETERS_KEY, value) } +private lateinit var userAllowingMonthlyReminderPushesFlow: MutableStateFlow + +fun Parameters.watchUserAllowingMonthlyReminderPushes(): StateFlow { + if (!::userAllowingMonthlyReminderPushesFlow.isInitialized) { + userAllowingMonthlyReminderPushesFlow = MutableStateFlow(isUserAllowingMonthlyReminderPushes()) + } + + return userAllowingMonthlyReminderPushesFlow +} + /** * The user wants or not to receive a daily monthly notification when report is available * @@ -319,6 +359,11 @@ fun Parameters.isUserAllowingMonthlyReminderPushes(): Boolean { * @param value if the user wants or not to receive monthly notifications */ fun Parameters.setUserAllowMonthlyReminderPushes(value: Boolean) { + if (!::userAllowingMonthlyReminderPushesFlow.isInitialized) { + userAllowingMonthlyReminderPushesFlow = MutableStateFlow(value) + } + + userAllowingMonthlyReminderPushesFlow.value = value putBoolean(USER_ALLOW_MONTHLY_PUSH_PARAMETERS_KEY, value) } @@ -338,6 +383,16 @@ fun Parameters.setUserHasCompleteRating() { putBoolean(RATING_COMPLETED_PARAMETERS_KEY, true) } +private lateinit var userSawMonthlyReportHintFlow: MutableStateFlow + +fun Parameters.watchUserSawMonthlyReportHint(): StateFlow { + if (!::userSawMonthlyReportHintFlow.isInitialized) { + userSawMonthlyReportHintFlow = MutableStateFlow(hasUserSawMonthlyReportHint()) + } + + return userSawMonthlyReportHintFlow +} + /** * Has the user saw the monthly report hint so far * @@ -351,26 +406,71 @@ fun Parameters.hasUserSawMonthlyReportHint(): Boolean { * Set that the user saw the monthly report hint */ fun Parameters.setUserSawMonthlyReportHint() { + if (!::userSawMonthlyReportHintFlow.isInitialized) { + userSawMonthlyReportHintFlow = MutableStateFlow(true) + } + + userSawMonthlyReportHintFlow.value = true putBoolean(USER_SAW_MONTHLY_REPORT_HINT_PARAMETERS_KEY, true) } +private lateinit var themeFlow: MutableStateFlow + +fun Parameters.watchTheme(): StateFlow { + if (!::themeFlow.isInitialized) { + themeFlow = MutableStateFlow(getTheme()) + } + + return themeFlow +} + fun Parameters.getTheme(): AppTheme { val value = getInt(APP_THEME_PARAMETERS_KEY, AppTheme.LIGHT.value) return AppTheme.entries.first { it.value == value } } fun Parameters.setTheme(theme: AppTheme) { + if (!::themeFlow.isInitialized) { + themeFlow = MutableStateFlow(theme) + } + + themeFlow.value = theme putInt(APP_THEME_PARAMETERS_KEY, theme.value) } +private lateinit var isBackupEnabledFlow: MutableStateFlow + +fun Parameters.watchIsBackupEnabled(): StateFlow { + if (!::isBackupEnabledFlow.isInitialized) { + isBackupEnabledFlow = MutableStateFlow(isBackupEnabled()) + } + + return isBackupEnabledFlow +} + fun Parameters.isBackupEnabled(): Boolean { return getBoolean(BACKUP_ENABLED_PARAMETERS_KEY, false) } fun Parameters.setBackupEnabled(enabled: Boolean) { + if (!::isBackupEnabledFlow.isInitialized) { + isBackupEnabledFlow = MutableStateFlow(enabled) + } + + isBackupEnabledFlow.value = enabled putBoolean(BACKUP_ENABLED_PARAMETERS_KEY, enabled) } +private lateinit var lastBackupDateFlow: MutableStateFlow + +fun Parameters.watchLastBackupDate(): StateFlow { + if (!::lastBackupDateFlow.isInitialized) { + lastBackupDateFlow = MutableStateFlow(getLastBackupDate()) + } + + return lastBackupDateFlow +} + fun Parameters.getLastBackupDate(): Date? { val lastTimestamp = getLong(LAST_BACKUP_TIMESTAMP, -1) if( lastTimestamp > 0 ) { @@ -381,6 +481,11 @@ fun Parameters.getLastBackupDate(): Date? { } fun Parameters.saveLastBackupDate(date: Date?) { + if (!::lastBackupDateFlow.isInitialized) { + lastBackupDateFlow = MutableStateFlow(date) + } + + lastBackupDateFlow.value = date if( date != null ) { putLong(LAST_BACKUP_TIMESTAMP, date.time) } else { @@ -419,14 +524,44 @@ fun Parameters.setShouldShowCheckedBalance(shouldShow: Boolean) { putBoolean(SHOULD_SHOW_CHECKED_BALANCE, shouldShow) } +private lateinit var latestSelectedOnlineAccountIdFlow: MutableStateFlow + +fun Parameters.watchLatestSelectedOnlineAccountId(): StateFlow { + if (!::latestSelectedOnlineAccountIdFlow.isInitialized) { + latestSelectedOnlineAccountIdFlow = MutableStateFlow(getLatestSelectedOnlineAccountId()) + } + + return latestSelectedOnlineAccountIdFlow +} + fun Parameters.getLatestSelectedOnlineAccountId(): String? { return getString(SELECTED_ACCOUNT_ID_KEY) } fun Parameters.setLatestSelectedOnlineAccountId(accountId: String?) { + if (!::latestSelectedOnlineAccountIdFlow.isInitialized) { + latestSelectedOnlineAccountIdFlow = MutableStateFlow(accountId) + } + + latestSelectedOnlineAccountIdFlow.value = accountId + if (accountId != null) { putString(SELECTED_ACCOUNT_ID_KEY, accountId) } else { remove(SELECTED_ACCOUNT_ID_KEY) } +} + +/** + * The current onboarding step (int) + */ +private const val ONBOARDING_STEP_PARAMETERS_KEY = "onboarding_step" +const val ONBOARDING_STEP_COMPLETED = Integer.MAX_VALUE + +fun Parameters.getOnboardingStep(): Int { + return getInt(ONBOARDING_STEP_PARAMETERS_KEY, 0) +} + +fun Parameters.setOnboardingStep(step: Int) { + putInt(ONBOARDING_STEP_PARAMETERS_KEY, step) } \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/DatePickerDialogFragment.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/DatePickerDialogFragment.kt deleted file mode 100644 index 042b6b6b..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/DatePickerDialogFragment.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view - -import android.app.DatePickerDialog -import android.app.Dialog -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import com.benoitletondor.easybudgetapp.helper.computeCalendarMinDateFromInitDate -import com.benoitletondor.easybudgetapp.helper.toStartOfDayDate -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.benoitletondor.easybudgetapp.parameters.getInitDate -import dagger.hilt.android.AndroidEntryPoint -import java.time.LocalDate -import javax.inject.Inject - -/** - * @author Benoit LETONDOR - */ -@AndroidEntryPoint -class DatePickerDialogFragment( - private val originalDate: LocalDate, - private val listener: DatePickerDialog.OnDateSetListener, -) : DialogFragment() { - @Inject lateinit var parameters: Parameters - - constructor() : this(LocalDate.now(), DatePickerDialog.OnDateSetListener { _, _, _, _ -> }) { - throw RuntimeException("DatePickerDialogFragment is supposed to be instanciated with the date+listener constructor") - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - // Create a new instance of DatePickerDialog and return it - val dialog = DatePickerDialog(requireContext(), listener, originalDate.year, originalDate.monthValue - 1, originalDate.dayOfMonth) - dialog.datePicker.minDate = (parameters.getInitDate() ?: LocalDate.now()).computeCalendarMinDateFromInitDate().toStartOfDayDate().time - return dialog - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/createaccount/CreateAccountActivity.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/createaccount/CreateAccountView.kt similarity index 62% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/createaccount/CreateAccountActivity.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/createaccount/CreateAccountView.kt index 96a88acc..9e172983 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/createaccount/CreateAccountActivity.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/createaccount/CreateAccountView.kt @@ -13,89 +13,98 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package com.benoitletondor.easybudgetapp.view.createaccount -package com.benoitletondor.easybudgetapp.view.main.createaccount - -import android.os.Bundle -import android.view.MenuItem import android.widget.Toast -import androidx.activity.viewModels import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.runtime.setValue -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.lifecycle.lifecycleScope +import androidx.hilt.navigation.compose.hiltViewModel import com.benoitletondor.easybudgetapp.R import com.benoitletondor.easybudgetapp.auth.CurrentUser -import com.benoitletondor.easybudgetapp.databinding.ActivityCreateAccountBinding -import com.benoitletondor.easybudgetapp.helper.BaseActivity +import com.benoitletondor.easybudgetapp.compose.AppTheme +import com.benoitletondor.easybudgetapp.compose.AppWithTopAppBarScaffold +import com.benoitletondor.easybudgetapp.compose.BackButtonBehavior import com.benoitletondor.easybudgetapp.helper.launchCollect -import com.benoitletondor.easybudgetapp.theme.AppTheme import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class CreateAccountActivity : BaseActivity() { - private val viewModel: CreateAccountViewModel by viewModels() - - override fun createBinding(): ActivityCreateAccountBinding = ActivityCreateAccountBinding.inflate(layoutInflater) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.Serializable - setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setDisplayHomeAsUpEnabled(true) +@Serializable +object CreateAccountDestination - binding.createAccountComposeView.setContent { - AppTheme { - val state by viewModel.stateFlow.collectAsState() +@Composable +fun CreateAccountView( + viewModel: CreateAccountViewModel = hiltViewModel(), + navigateUp: () -> Unit, + finish: () -> Unit, +) { + CreateAccountView( + stateFlow = viewModel.stateFlow, + eventFlow = viewModel.eventFlow, + navigateUp = navigateUp, + finish = finish, + onFinishButtonClicked = viewModel::onFinishButtonClicked, + onCreateAccountClicked = viewModel::onCreateAccountButtonPressed, + ) +} - ContentView( - state = state, - onFinishButtonClicked = viewModel::onFinishButtonClicked, - onCreateAccountClicked = viewModel::onCreateAccountButtonPressed, - ) - } - } +@Composable +private fun CreateAccountView( + stateFlow: StateFlow, + eventFlow: Flow, + navigateUp: () -> Unit, + finish: () -> Unit, + onFinishButtonClicked: () -> Unit, + onCreateAccountClicked: (String) -> Unit, +) { + val context = LocalContext.current - lifecycleScope.launchCollect(viewModel.eventFlow) { event -> + LaunchedEffect(key1 = "eventsListener") { + launchCollect(eventFlow) { event -> when(event) { CreateAccountViewModel.Event.Finish -> finish() is CreateAccountViewModel.Event.ErrorWhileCreatingAccount -> { - MaterialAlertDialogBuilder(this) + MaterialAlertDialogBuilder(context) .setTitle(R.string.account_creation_success_error_title) - .setMessage(getString(R.string.account_creation_success_error_message, event.error.localizedMessage)) + .setMessage(context.getString(R.string.account_creation_success_error_message, event.error.localizedMessage)) .setPositiveButton(R.string.ok) { dialog, _ -> dialog.dismiss() } .show() } CreateAccountViewModel.Event.SuccessCreatingAccount -> Toast.makeText( - this, + context, R.string.account_creation_success_toast, Toast.LENGTH_LONG, ).show() @@ -103,35 +112,33 @@ class CreateAccountActivity : BaseActivity() { } } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - - if (id == android.R.id.home) { - finish() - return true - } - - return super.onOptionsItemSelected(item) - } -} + AppWithTopAppBarScaffold( + title = stringResource(R.string.title_activity_create_account), + backButtonBehavior = BackButtonBehavior.NavigateBack( + onBackButtonPressed = navigateUp, + ), + content = { contentPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + val state by stateFlow.collectAsState() -@Composable -private fun ContentView( - state: CreateAccountViewModel.State, - onFinishButtonClicked: () -> Unit, - onCreateAccountClicked: (String) -> Unit, -) { - when(state) { - is CreateAccountViewModel.State.Creating -> LoadingView(isCreating = true) - CreateAccountViewModel.State.Loading -> LoadingView(isCreating = false) - CreateAccountViewModel.State.NotAuthenticatedError -> NotAuthenticatedView( - onFinishButtonClicked = onFinishButtonClicked, - ) - is CreateAccountViewModel.State.Ready -> CreateAccountView( - initialNameValue = state.initialNameValue, - onCreateAccountClicked = onCreateAccountClicked, - ) - } + when(val currentState = state) { + is CreateAccountViewModel.State.Creating -> LoadingView(isCreating = true) + CreateAccountViewModel.State.Loading -> LoadingView(isCreating = false) + CreateAccountViewModel.State.NotAuthenticatedError -> NotAuthenticatedView( + onFinishButtonClicked = onFinishButtonClicked, + ) + is CreateAccountViewModel.State.Ready -> CreateAccountView( + initialNameValue = currentState.initialNameValue, + onCreateAccountClicked = onCreateAccountClicked, + ) + } + } + }, + ) } @Composable @@ -233,31 +240,20 @@ private fun NotAuthenticatedView( private fun LoadingView( isCreating: Boolean, ) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(horizontal = 20.dp), - ) { - CircularProgressIndicator() - - if (isCreating) { - Spacer(modifier = Modifier.height(10.dp)) - - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - text = stringResource(R.string.create_account_creating_placeholder), - ) - } - } + com.benoitletondor.easybudgetapp.compose.components.LoadingView( + loadingText = if (isCreating) stringResource(R.string.create_account_creating_placeholder) else null + ) } @Composable @Preview(name = "Loading state preview", showSystemUi = true) private fun LoadingStatePreview() { AppTheme { - ContentView( - state = CreateAccountViewModel.State.Loading, + CreateAccountView( + stateFlow = MutableStateFlow(CreateAccountViewModel.State.Loading), + eventFlow = MutableSharedFlow(), + navigateUp = {}, + finish = {}, onFinishButtonClicked = {}, onCreateAccountClicked = {}, ) @@ -268,8 +264,11 @@ private fun LoadingStatePreview() { @Preview(name = "Creating state preview", showSystemUi = true) private fun CreatingStatePreview() { AppTheme { - ContentView( - state = CreateAccountViewModel.State.Creating(currentUser = CurrentUser("", "", "")), + CreateAccountView( + stateFlow = MutableStateFlow(CreateAccountViewModel.State.Creating(currentUser = CurrentUser("", "", ""))), + eventFlow = MutableSharedFlow(), + navigateUp = {}, + finish = {}, onFinishButtonClicked = {}, onCreateAccountClicked = {}, ) @@ -280,8 +279,11 @@ private fun CreatingStatePreview() { @Preview(name = "Not authenticated preview", showSystemUi = true) private fun NotAuthenticatedStatePreview() { AppTheme { - ContentView( - state = CreateAccountViewModel.State.NotAuthenticatedError, + CreateAccountView( + stateFlow = MutableStateFlow(CreateAccountViewModel.State.NotAuthenticatedError), + eventFlow = MutableSharedFlow(), + navigateUp = {}, + finish = {}, onFinishButtonClicked = {}, onCreateAccountClicked = {}, ) @@ -292,13 +294,16 @@ private fun NotAuthenticatedStatePreview() { @Preview(name = "Ready preview", showSystemUi = true) private fun ReadyStatePreview() { AppTheme { - ContentView( - state = CreateAccountViewModel.State.Ready( + CreateAccountView( + stateFlow = MutableStateFlow(CreateAccountViewModel.State.Ready( initialNameValue = "", currentUser = CurrentUser("", "", ""), - ), + )), + eventFlow = MutableSharedFlow(), + navigateUp = {}, + finish = {}, onFinishButtonClicked = {}, onCreateAccountClicked = {}, ) } -} +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/createaccount/CreateAccountViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/createaccount/CreateAccountViewModel.kt similarity index 98% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/createaccount/CreateAccountViewModel.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/createaccount/CreateAccountViewModel.kt index e27ccf17..6aa2e348 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/createaccount/CreateAccountViewModel.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/createaccount/CreateAccountViewModel.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.benoitletondor.easybudgetapp.view.main.createaccount +package com.benoitletondor.easybudgetapp.view.createaccount import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/expenseedit/ExpenseEditActivity.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/expenseedit/ExpenseEditActivity.kt deleted file mode 100644 index b5a7bd1a..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/expenseedit/ExpenseEditActivity.kt +++ /dev/null @@ -1,258 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.expenseedit - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.MenuItem -import androidx.activity.viewModels -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.databinding.ActivityExpenseEditBinding -import com.benoitletondor.easybudgetapp.helper.* -import com.benoitletondor.easybudgetapp.model.Expense -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.benoitletondor.easybudgetapp.view.DatePickerDialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.util.* -import javax.inject.Inject -import kotlin.math.abs - -/** - * Activity to add a new expense - * - * @author Benoit LETONDOR - */ -@AndroidEntryPoint -class ExpenseEditActivity : BaseActivity() { - private val viewModel: ExpenseEditViewModel by viewModels() - - @Inject lateinit var parameters: Parameters - -// --------------------------------------> - - override fun createBinding(): ActivityExpenseEditBinding = ActivityExpenseEditBinding.inflate(layoutInflater) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setSupportActionBar(binding.toolbar) - - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - val existingExpenseData = viewModel.existingExpenseData - if (existingExpenseData != null) { - setUpTextFields(existingExpenseData.title, existingExpenseData.amount) - } else { - setUpTextFields(description = null, amount = null) - } - - setUpButtons() - - - binding.descriptionEdittext.setFocus() - binding.saveExpenseFab.animateFABAppearance() - - lifecycleScope.launchCollect(viewModel.editTypeFlow) { (isRevenue, isEdit) -> - setExpenseTypeTextViewLayout(isRevenue, isEdit) - } - - lifecycleScope.launchCollect(viewModel.expenseDateFlow) { date -> - setUpDateButton(date) - } - - lifecycleScope.launchCollect(viewModel.unableToLoadDBEventFlow) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.expense_edit_unable_to_load_db_error_title) - .setMessage(R.string.expense_edit_unable_to_load_db_error_message) - .setPositiveButton(R.string.expense_edit_unable_to_load_db_error_cta) { _, _ -> - finish() - } - .setCancelable(false) - .show() - } - - lifecycleScope.launchCollect(viewModel.finishFlow) { - finish() - } - - lifecycleScope.launchCollect(viewModel.expenseAddBeforeInitDateEventFlow) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.expense_add_before_init_date_dialog_title) - .setMessage(R.string.expense_add_before_init_date_dialog_description) - .setPositiveButton(R.string.expense_add_before_init_date_dialog_positive_cta) { _, _ -> - viewModel.onAddExpenseBeforeInitDateConfirmed( - getCurrentAmount(), - binding.descriptionEdittext.text.toString() - ) - } - .setNegativeButton(R.string.expense_add_before_init_date_dialog_negative_cta) { _, _ -> - viewModel.onAddExpenseBeforeInitDateCancelled() - } - .show() - } - } - -// -----------------------------------> - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - finish() - return true - } - } - - return super.onOptionsItemSelected(item) - } - - /** - * Validate user inputs - * - * @return true if user inputs are ok, false otherwise - */ - private fun validateInputs(): Boolean { - var ok = true - - val description = binding.descriptionEdittext.text.toString() - if (description.trim { it <= ' ' }.isEmpty()) { - binding.descriptionEdittext.error = resources.getString(R.string.no_description_error) - ok = false - } - - val amount = binding.amountEdittext.text.toString() - if (amount.trim { it <= ' ' }.isEmpty()) { - binding.amountEdittext.error = resources.getString(R.string.no_amount_error) - ok = false - } else { - try { - val value = java.lang.Double.valueOf(amount) - if (value <= 0) { - binding.amountEdittext.error = resources.getString(R.string.negative_amount_error) - ok = false - } - } catch (e: Exception) { - binding.amountEdittext.error = resources.getString(R.string.invalid_amount) - ok = false - } - } - - return ok - } - - /** - * Set-up revenue and payment buttons - */ - private fun setUpButtons() { - binding.expenseTypeSwitch.setOnCheckedChangeListener { _, isChecked -> - viewModel.onExpenseRevenueValueChanged(isChecked) - } - - binding.expenseTypeTv.setOnClickListener { - viewModel.onExpenseRevenueValueChanged(!binding.expenseTypeSwitch.isChecked) - } - - binding.saveExpenseFab.setOnClickListener { - if (validateInputs()) { - viewModel.onSave(getCurrentAmount(), binding.descriptionEdittext.text.toString()) - } - } - } - - /** - * Set revenue text view layout - */ - private fun setExpenseTypeTextViewLayout(isRevenue: Boolean, isEdit: Boolean) { - if (isRevenue) { - binding.expenseTypeTv.setText(R.string.income) - binding.expenseTypeTv.setTextColor(ContextCompat.getColor(this, R.color.budget_green)) - - binding.expenseTypeSwitch.isChecked = true - - setTitle(if (isEdit) R.string.title_activity_edit_income else R.string.title_activity_add_income) - } else { - binding.expenseTypeTv.setText(R.string.payment) - binding.expenseTypeTv.setTextColor(ContextCompat.getColor(this, R.color.budget_red)) - - binding.expenseTypeSwitch.isChecked = false - - setTitle(if (isEdit) R.string.title_activity_edit_expense else R.string.title_activity_add_expense) - } - } - - /** - * Set up text field focus behavior - */ - private fun setUpTextFields(description: String?, amount: Double?) { - binding.amountInputlayout.hint = resources.getString(R.string.amount, parameters.getUserCurrency().symbol) - - if (description != null) { - binding.descriptionEdittext.setText(description) - binding.descriptionEdittext.setSelection(binding.descriptionEdittext.text?.length ?: 0) // Put focus at the end of the text - } - - binding.amountEdittext.preventUnsupportedInputForDecimals() - - if (amount != null) { - binding.amountEdittext.setText(CurrencyHelper.getFormattedAmountValue(abs(amount))) - } - } - - /** - * Set up the date button - */ - private fun setUpDateButton(date: LocalDate) { - val formatter = DateTimeFormatter.ofPattern(resources.getString(R.string.add_expense_date_format), Locale.getDefault()) - binding.dateButton.text = formatter.format(date) - - binding.dateButton.setOnClickListener { - val fragment = DatePickerDialogFragment(date) { _, year, monthOfYear, dayOfMonth -> - viewModel.onDateChanged(LocalDate.of(year, monthOfYear + 1, dayOfMonth)) - } - - fragment.show(supportFragmentManager, "datePicker") - } - } - - private fun getCurrentAmount(): Double { - return java.lang.Double.parseDouble(binding.amountEdittext.text.toString()) - } - - companion object { - const val ARG_EDITED_EXPENSE = "expense" - const val ARG_DATE = "date" - - fun newIntent( - context: Context, - editedExpense: Expense?, - date: LocalDate, - ): Intent { - return Intent(context, ExpenseEditActivity::class.java).apply { - putExtra(ARG_DATE, date.toEpochDay()) - if (editedExpense != null) { - putExtra(ARG_EDITED_EXPENSE, editedExpense) - } - } - } - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/expenseedit/ExpenseEditView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/expenseedit/ExpenseEditView.kt new file mode 100644 index 00000000..ae4139b6 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/expenseedit/ExpenseEditView.kt @@ -0,0 +1,421 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.expenseedit + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.compose.AppWithTopAppBarScaffold +import com.benoitletondor.easybudgetapp.compose.BackButtonBehavior +import com.benoitletondor.easybudgetapp.compose.components.ExpenseEditTextField +import com.benoitletondor.easybudgetapp.helper.CurrencyHelper +import com.benoitletondor.easybudgetapp.helper.SerializedExpense +import com.benoitletondor.easybudgetapp.helper.launchCollect +import com.benoitletondor.easybudgetapp.helper.sanitizeFromUnsupportedInputForDecimals +import com.benoitletondor.easybudgetapp.model.Expense +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.Serializable +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Currency +import java.util.Date +import java.util.Locale +import kotlin.math.abs + +@Serializable +data class ExpenseAddDestination(val dateEpochDay: Long) { + constructor( + date: LocalDate, + ) : this(date.toEpochDay()) +} + +@Serializable +data class ExpenseEditDestination(val dateEpochDay: Long, val editedExpense: SerializedExpense) { + constructor( + date: LocalDate, + editedExpense: Expense, + ) : this(date.toEpochDay(), SerializedExpense(editedExpense)) +} + +@Composable +fun ExpenseEditView( + viewModel: ExpenseEditViewModel, + navigateUp: () -> Unit, + finish: () -> Unit, +) { + ExpenseEditView( + stateFlow = viewModel.stateFlow, + eventFlow = viewModel.eventFlow, + userCurrencyFlow = viewModel.userCurrencyFlow, + navigateUp = navigateUp, + finish = finish, + onTitleUpdate = viewModel::onTitleChanged, + onAmountUpdate = viewModel::onAmountChanged, + onSaveButtonClicked = viewModel::onSave, + onIsRevenueChanged = viewModel::onExpenseRevenueValueChanged, + onDateClicked = viewModel::onDateClicked, + onDateSelected = viewModel::onDateSelected, + onAddExpenseBeforeInitDateConfirmed = viewModel::onAddExpenseBeforeInitDateConfirmed, + onAddExpenseBeforeInitDateCancelled = viewModel::onAddExpenseBeforeInitDateCancelled, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ExpenseEditView( + stateFlow: StateFlow, + eventFlow: Flow, + userCurrencyFlow: StateFlow, + navigateUp: () -> Unit, + finish: () -> Unit, + onTitleUpdate: (String) -> Unit, + onAmountUpdate: (String) -> Unit, + onSaveButtonClicked: () -> Unit, + onIsRevenueChanged: (Boolean) -> Unit, + onDateClicked: () -> Unit, + onDateSelected: (Long?) -> Unit, + onAddExpenseBeforeInitDateConfirmed: () -> Unit, + onAddExpenseBeforeInitDateCancelled: () -> Unit, +) { + val context = LocalContext.current + var showDatePickerWithDate by remember { mutableStateOf(null) } + var amountValueError: String? by remember { mutableStateOf(null) } + var titleValueError: String? by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = "eventsListener") { + launchCollect(eventFlow) { event -> + when (event) { + ExpenseEditViewModel.Event.ExpenseAddBeforeInitDateError -> MaterialAlertDialogBuilder(context) + .setTitle(R.string.expense_add_before_init_date_dialog_title) + .setMessage(R.string.expense_add_before_init_date_dialog_description) + .setPositiveButton(R.string.expense_add_before_init_date_dialog_positive_cta) { _, _ -> + onAddExpenseBeforeInitDateConfirmed() + } + .setNegativeButton(R.string.expense_add_before_init_date_dialog_negative_cta) { _, _ -> + onAddExpenseBeforeInitDateCancelled() + } + .show() + ExpenseEditViewModel.Event.Finish -> finish() + ExpenseEditViewModel.Event.UnableToLoadDB -> MaterialAlertDialogBuilder(context) + .setTitle(R.string.expense_edit_unable_to_load_db_error_title) + .setMessage(R.string.expense_edit_unable_to_load_db_error_message) + .setPositiveButton(R.string.expense_edit_unable_to_load_db_error_cta) { _, _ -> + finish() + } + .setCancelable(false) + .show() + is ExpenseEditViewModel.Event.ErrorPersistingExpense -> MaterialAlertDialogBuilder(context) + .setTitle(R.string.expense_edit_error_saving_title) + .setMessage(R.string.expense_edit_error_saving_message) + .setPositiveButton(R.string.ok) { dialog, _ -> dialog.dismiss() } + .show() + ExpenseEditViewModel.Event.EmptyTitleError -> titleValueError = context.getString(R.string.no_description_error) + is ExpenseEditViewModel.Event.ShowDatePicker -> showDatePickerWithDate = event.date + ExpenseEditViewModel.Event.EmptyAmountError -> amountValueError = context.getString(R.string.no_amount_error) + } + } + } + + val state by stateFlow.collectAsState() + + AppWithTopAppBarScaffold( + title = stringResource(if (state.isEditing) { + if (state.isRevenue) { R.string.title_activity_edit_income } else { R.string.title_activity_edit_expense } + } else { + if (state.isRevenue) { R.string.title_activity_add_income } else { R.string.title_activity_add_expense } + }), + backButtonBehavior = BackButtonBehavior.NavigateBack( + onBackButtonPressed = navigateUp, + ), + content = { contentPadding -> + val titleFocusRequester = remember { FocusRequester() } + val amountFocusRequester = remember { FocusRequester() } + LaunchedEffect(key1 = "focusRequester") { + titleFocusRequester.requestFocus() + } + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = colorResource(R.color.action_bar_background)) + .padding(horizontal = 26.dp) + .padding(top = 10.dp, bottom = 20.dp), + ) { + var descriptionTextFieldValue by remember { mutableStateOf( + TextFieldValue( + text = state.expense.title, + selection = TextRange(index = state.expense.title.length), + ) + ) } + + ExpenseEditTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(titleFocusRequester), + value = descriptionTextFieldValue, + onValueChange = { + descriptionTextFieldValue = it + titleValueError = null + onTitleUpdate(it.text) + }, + isError = titleValueError != null, + label = if (titleValueError != null ) "${stringResource(R.string.description)}: $titleValueError" else stringResource(R.string.description), + keyboardActions = KeyboardActions( + onNext = { + titleFocusRequester.freeFocus() + amountFocusRequester.requestFocus() + }, + ), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Text, + capitalization = KeyboardCapitalization.Sentences, + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val currency by userCurrencyFlow.collectAsState() + + var currentAmountTextFieldValue by remember { mutableStateOf( + TextFieldValue( + text = if (state.expense.amount == 0.0) "" else CurrencyHelper.getFormattedAmountValue(abs(state.expense.amount)), + selection = TextRange(index = if (state.expense.amount == 0.0) 0 else CurrencyHelper.getFormattedAmountValue(abs(state.expense.amount)).length), + ) + ) } + + ExpenseEditTextField( + modifier = Modifier + .fillMaxWidth(0.5f) + .focusRequester(amountFocusRequester), + value = currentAmountTextFieldValue, + onValueChange = { newValue -> + val newText = newValue.text.sanitizeFromUnsupportedInputForDecimals(supportsNegativeValue = false) + + currentAmountTextFieldValue = TextFieldValue( + text = newText, + selection = newValue.selection, + ) + + amountValueError = null + onAmountUpdate(newText) + }, + isError = amountValueError != null, + label = if (amountValueError != null) "${stringResource(R.string.amount, currency.symbol)}: $amountValueError" else stringResource(R.string.amount, currency.symbol), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + ) + ) + + Spacer(modifier = Modifier.height(20.dp)) + } + + FloatingActionButton( + modifier = Modifier + .align(Alignment.End) + .offset(y = (-30).dp, x = (-26).dp), + onClick = onSaveButtonClicked, + containerColor = colorResource(R.color.secondary), + contentColor = colorResource(R.color.white), + ) { + Icon( + painter = painterResource(R.drawable.ic_save_white_24dp), + contentDescription = stringResource(R.string.fab_add_expense), + ) + } + + Row( + modifier = Modifier + .offset(y = (-20).dp) + .fillMaxWidth() + .padding(horizontal = 26.dp), + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(R.string.type), + color = colorResource(R.color.expense_edit_title_text_color), + fontSize = 14.sp, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + checked = state.isRevenue, + onCheckedChange = onIsRevenueChanged, + colors = SwitchDefaults.colors( + checkedTrackColor = colorResource(R.color.add_expense_expense_thumb_background_color), + checkedThumbColor = colorResource(R.color.budget_green), + uncheckedThumbColor = colorResource(R.color.budget_red), + uncheckedTrackColor = colorResource(R.color.add_expense_expense_thumb_background_color), + uncheckedBorderColor = Color.Transparent, + checkedBorderColor = Color.Transparent, + ), + thumbContent = { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + ) + } + ) + + Spacer(modifier = Modifier.width(5.dp)) + + Text( + text = stringResource(if(state.isRevenue) R.string.income else R.string.payment), + color = colorResource(if(state.isRevenue) R.color.budget_green else R.color.budget_red), + fontSize = 14.sp, + ) + } + + + } + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(R.string.date), + color = colorResource(R.color.expense_edit_title_text_color), + fontSize = 14.sp, + ) + + Spacer(modifier = Modifier.height(5.dp)) + + val dateFormatter = remember { + DateTimeFormatter.ofPattern(context.getString(R.string.add_expense_date_format), Locale.getDefault()) + } + val dateString = remember(state.expense.date) { + dateFormatter.format(state.expense.date) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onDateClicked) + .padding(top = 3.dp), + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = dateString, + textAlign = TextAlign.Center, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = colorResource(R.color.expense_edit_field_accent_color), + thickness = 1.dp, + ) + } + } + } + } + } + + val datePickerDate = showDatePickerWithDate + if (datePickerDate != null) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = Date.from(datePickerDate.atStartOfDay().atZone(ZoneId.of("UTC")).toInstant()).time + ) + + DatePickerDialog( + onDismissRequest = { showDatePickerWithDate = null }, + confirmButton = { + Button( + modifier = Modifier.padding(end = 16.dp, bottom = 10.dp), + onClick = { + onDateSelected(datePickerState.selectedDateMillis) + showDatePickerWithDate = null + } + ) { + Text(text = stringResource(R.string.ok)) + } + }, + content = { + DatePicker(state = datePickerState) + }, + ) + } + }, + ) +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/expenseedit/ExpenseEditViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/expenseedit/ExpenseEditViewModel.kt index a07396cc..61cb1175 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/expenseedit/ExpenseEditViewModel.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/expenseedit/ExpenseEditViewModel.kt @@ -16,67 +16,97 @@ package com.benoitletondor.easybudgetapp.view.expenseedit -import androidx.lifecycle.SavedStateHandle +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.benoitletondor.easybudgetapp.db.DB +import com.benoitletondor.easybudgetapp.helper.Logger import com.benoitletondor.easybudgetapp.model.Expense import com.benoitletondor.easybudgetapp.helper.MutableLiveFlow +import com.benoitletondor.easybudgetapp.helper.watchUserCurrency +import com.benoitletondor.easybudgetapp.injection.CurrentDBProvider import com.benoitletondor.easybudgetapp.parameters.Parameters import com.benoitletondor.easybudgetapp.parameters.getInitDate -import com.benoitletondor.easybudgetapp.view.main.account.AccountViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.time.Instant import java.time.LocalDate -import javax.inject.Inject +import java.time.ZoneId +import kotlin.math.abs -@HiltViewModel -class ExpenseEditViewModel @Inject constructor( +@HiltViewModel(assistedFactory = ExpenseEditViewModelFactory::class) +class ExpenseEditViewModel @AssistedInject constructor( private val parameters: Parameters, - savedStateHandle: SavedStateHandle, + currentDBProvider: CurrentDBProvider, + @Assisted private val editedExpense: Expense?, + @Assisted date: LocalDate, ) : ViewModel() { - /** - * Expense that is being edited (will be null if it's a new one) - */ - private val editedExpense: Expense? = savedStateHandle.get(ExpenseEditActivity.ARG_EDITED_EXPENSE) - - private val expenseDateMutableStateFlow = MutableStateFlow(LocalDate.ofEpochDay( - savedStateHandle[ExpenseEditActivity.ARG_DATE] ?: throw IllegalStateException("No ARG_DATE arg"))) - val expenseDateFlow: Flow = expenseDateMutableStateFlow - - private val editTypeMutableStateFlow = MutableStateFlow(ExpenseEditType( - editedExpense?.isRevenue() ?: false, - editedExpense != null - )) - val editTypeFlow: Flow = editTypeMutableStateFlow - - val existingExpenseData = editedExpense?.let { expense -> - ExistingExpenseData( - expense.title, - expense.amount, + private val dateMutableStateFlow = MutableStateFlow(date) + private val amountMutableStateFlow = MutableStateFlow(editedExpense?.amount ?: 0.0) + private val isRevenueMutableStateFlow = MutableStateFlow(editedExpense?.isRevenue() ?: false) + private val titleMutableStateFlow = MutableStateFlow(editedExpense?.title ?: "") + private val isSavingMutableStateFlow = MutableStateFlow(false) + + val userCurrencyFlow = parameters.watchUserCurrency() + + val stateFlow: StateFlow = combine( + dateMutableStateFlow, + amountMutableStateFlow, + isRevenueMutableStateFlow, + titleMutableStateFlow, + isSavingMutableStateFlow, + ) { date, amount, isRevenue, title, isSaving -> + val expense = Expense( + id = editedExpense?.id, + title = title, + amount = if (isRevenue) -abs(amount) else abs(amount), + date = date, + checked = editedExpense?.checked ?: false, + associatedRecurringExpense = editedExpense?.associatedRecurringExpense, ) - } - private val expenseAddBeforeInitDateErrorMutableFlow = MutableLiveFlow() - val expenseAddBeforeInitDateEventFlow: Flow = expenseAddBeforeInitDateErrorMutableFlow - - private val unableToLoadDBEventMutableFlow = MutableLiveFlow() - val unableToLoadDBEventFlow: Flow = unableToLoadDBEventMutableFlow + return@combine State( + isEditing = editedExpense != null, + isSaving = isSaving, + isRevenue = isRevenue, + expense = expense, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, State( + isEditing = editedExpense != null, + isRevenue = editedExpense?.isRevenue() ?: false, + isSaving = false, + expense = Expense( + id = editedExpense?.id, + title = titleMutableStateFlow.value, + amount = if (isRevenueMutableStateFlow.value) -abs(amountMutableStateFlow.value) else abs(amountMutableStateFlow.value), + date = dateMutableStateFlow.value, + checked = editedExpense?.checked ?: false, + associatedRecurringExpense = editedExpense?.associatedRecurringExpense, + ) + )) - private val finishMutableFlow = MutableLiveFlow() - val finishFlow: Flow = finishMutableFlow + private val eventMutableFlow = MutableLiveFlow() + val eventFlow: Flow = eventMutableFlow private lateinit var db: DB init { - val currentDb = AccountViewModel.getCurrentDB() + val currentDb = currentDBProvider.activeDB if (currentDb == null) { viewModelScope.launch { - unableToLoadDBEventMutableFlow.emit(Unit) + eventMutableFlow.emit(Event.UnableToLoadDB) } } else { db = currentDb @@ -84,60 +114,116 @@ class ExpenseEditViewModel @Inject constructor( } fun onExpenseRevenueValueChanged(isRevenue: Boolean) { - editTypeMutableStateFlow.value = ExpenseEditType(isRevenue, editedExpense != null) + isRevenueMutableStateFlow.value = isRevenue + } + + fun onDateSelected(utcTimestamp: Long?) { + if (utcTimestamp != null) { + dateMutableStateFlow.value = Instant.ofEpochMilli(utcTimestamp) + .atZone(ZoneId.of("UTC")) + .toLocalDate() + } } - fun onSave(value: Double, description: String) { - val isRevenue = editTypeMutableStateFlow.value.isRevenue - val date = expenseDateMutableStateFlow.value + fun onDateClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowDatePicker(dateMutableStateFlow.value)) + } + } - val dateOfInstallation = parameters.getInitDate() ?: LocalDate.now() + fun onAmountChanged(amount: String) { + amountMutableStateFlow.value = amount.toDoubleOrNull() ?: 0.0 + } + + fun onTitleChanged(title: String) { + titleMutableStateFlow.value = title + } + fun onSave() { + var isInError = false + if (titleMutableStateFlow.value.isEmpty()) { + isInError = true + viewModelScope.launch { + eventMutableFlow.emit(Event.EmptyTitleError) + } + } + + if (amountMutableStateFlow.value == 0.0) { + isInError = true + viewModelScope.launch { + eventMutableFlow.emit(Event.EmptyAmountError) + } + } + + if (isInError) { + return + } + + val date = dateMutableStateFlow.value + val dateOfInstallation = parameters.getInitDate() ?: LocalDate.now() if( date.isBefore(dateOfInstallation) ) { viewModelScope.launch { - expenseAddBeforeInitDateErrorMutableFlow.emit(Unit) + eventMutableFlow.emit(Event.ExpenseAddBeforeInitDateError) } return } - doSaveExpense(value, description, isRevenue, date) + doSaveExpense(stateFlow.value.expense) } - fun onAddExpenseBeforeInitDateConfirmed(value: Double, description: String) { - val isRevenue = editTypeMutableStateFlow.value.isRevenue - val date = expenseDateMutableStateFlow.value - - doSaveExpense(value, description, isRevenue, date) + fun onAddExpenseBeforeInitDateConfirmed() { + doSaveExpense(stateFlow.value.expense) } fun onAddExpenseBeforeInitDateCancelled() { // No-op } - private fun doSaveExpense(value: Double, description: String, isRevenue: Boolean, date: LocalDate) { + private fun doSaveExpense(expense: Expense) { viewModelScope.launch { - withContext(Dispatchers.Default) { - val expense = editedExpense?.copy( - title = description, - amount = if (isRevenue) -value else value, - date = date, - ) ?: Expense(description, if (isRevenue) -value else value, date, false) - - db.persistExpense(expense) + isSavingMutableStateFlow.value = true - withContext(Dispatchers.Main) { - finishMutableFlow.emit(Unit) + try { + withContext(Dispatchers.IO) { + db.persistExpense(expense) } + + eventMutableFlow.emit(Event.Finish) + } catch (e: Throwable) { + if (e is CancellationException) throw e + + Logger.error("Error while persisting expense", e) + eventMutableFlow.emit(Event.ErrorPersistingExpense(e)) + } finally { + isSavingMutableStateFlow.value = false } } } - fun onDateChanged(date: LocalDate) { - expenseDateMutableStateFlow.value = date + sealed class Event { + data object Finish : Event() + data object UnableToLoadDB : Event() + data object EmptyTitleError : Event() + data object EmptyAmountError : Event() + data object ExpenseAddBeforeInitDateError : Event() + data class ErrorPersistingExpense(val error: Throwable) : Event() + data class ShowDatePicker(val date: LocalDate) : Event() } -} -data class ExpenseEditType(val isRevenue: Boolean, val editing: Boolean) + @Immutable + data class State( + val isEditing: Boolean, + val isSaving: Boolean, + val expense: Expense, + val isRevenue: Boolean, + ) +} -data class ExistingExpenseData(val title: String, val amount: Double) \ No newline at end of file +@AssistedFactory +interface ExpenseEditViewModelFactory { + fun create( + date: LocalDate, + editedExpense: Expense?, + ): ExpenseEditViewModel +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/login/LoginActivity.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/login/LoginView.kt similarity index 62% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/login/LoginActivity.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/login/LoginView.kt index 1297f9f8..7c1d53cc 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/login/LoginActivity.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/login/LoginView.kt @@ -13,17 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package com.benoitletondor.easybudgetapp.view.login -package com.benoitletondor.easybudgetapp.view.main.login - -import android.content.Context import android.content.Intent -import android.os.Bundle -import android.view.MenuItem -import androidx.activity.viewModels +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -31,12 +31,12 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -45,97 +45,94 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.lifecycleScope import com.benoitletondor.easybudgetapp.R import com.benoitletondor.easybudgetapp.auth.CurrentUser -import com.benoitletondor.easybudgetapp.databinding.ActivityLoginBinding -import com.benoitletondor.easybudgetapp.helper.BaseActivity +import com.benoitletondor.easybudgetapp.compose.AppTheme +import com.benoitletondor.easybudgetapp.compose.AppWithTopAppBarScaffold +import com.benoitletondor.easybudgetapp.compose.BackButtonBehavior +import com.benoitletondor.easybudgetapp.compose.components.LoadingView import com.benoitletondor.easybudgetapp.helper.launchCollect -import com.benoitletondor.easybudgetapp.theme.AppTheme -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class LoginActivity : BaseActivity() { - private val viewModel: LoginViewModel by viewModels() - - override fun createBinding(): ActivityLoginBinding = ActivityLoginBinding.inflate(layoutInflater) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.Serializable - setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setDisplayHomeAsUpEnabled(true) +@Serializable +data class LoginDestination(val shouldDismissAfterAuth: Boolean) - binding.loginComposeView.setContent { - AppTheme { - val state by viewModel.stateFlow.collectAsState() +@Composable +fun LoginView( + viewModel: LoginViewModel, + navigateUp: () -> Unit, + finish: () -> Unit, +) { + LoginView( + stateFlow = viewModel.stateFlow, + eventFlow = viewModel.eventFlow, + navigateUp = navigateUp, + onLogoutButtonPressed = viewModel::onLogoutButtonClicked, + onFinishButtonPressed = viewModel::onFinishButtonPressed, + onLoginButtonPressed = viewModel::onAuthenticatedButtonClicked, + onAuthActivityResult = { + viewModel.handleAuthActivityResult(it.resultCode, it.data) + }, + finish = finish, + ) +} - ContentView( - state = state, - onLogoutButtonPressed = viewModel::onLogoutButtonClicked, - onFinishButtonPressed = viewModel::onFinishButtonPressed, - onLoginButtonPressed = { - viewModel.onAuthenticatedButtonClicked(this) - } - ) - } - } +@Composable +private fun LoginView( + stateFlow: StateFlow, + eventFlow: Flow, + navigateUp: () -> Unit, + onLogoutButtonPressed: () -> Unit, + onFinishButtonPressed: () -> Unit, + onLoginButtonPressed: (ManagedActivityResultLauncher) -> Unit, + onAuthActivityResult: (ActivityResult) -> Unit, + finish: () -> Unit, +) { + val authActivityLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + onAuthActivityResult(result) + } - lifecycleScope.launchCollect(viewModel.eventFlow) { event -> + LaunchedEffect(key1 = "eventsListener") { + launchCollect(eventFlow) { event -> when(event) { LoginViewModel.Event.Finish -> finish() } } } - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - - viewModel.handleActivityResult(requestCode, resultCode, data) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - - if (id == android.R.id.home) { - finish() - return true - } - - return super.onOptionsItemSelected(item) - } - - companion object { - const val SHOULD_DISMISS_AFTER_AUTH_EXTRA = "shouldDismissAfterAuth" - - fun newIntent(context: Context, shouldDismissAfterAuth: Boolean): Intent { - return Intent(context, LoginActivity::class.java).apply { - putExtra(SHOULD_DISMISS_AFTER_AUTH_EXTRA, shouldDismissAfterAuth) + AppWithTopAppBarScaffold( + title = stringResource(R.string.title_activity_login), + backButtonBehavior = BackButtonBehavior.NavigateBack( + onBackButtonPressed = navigateUp, + ), + content = { contentPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + val state by stateFlow.collectAsState() + + when(val currentState = state) { + is LoginViewModel.State.Authenticated -> AuthenticatedView( + currentUser = currentState.user, + onLogoutButtonPressed = onLogoutButtonPressed, + onFinishButtonPressed = onFinishButtonPressed, + ) + LoginViewModel.State.Loading -> LoadingView() + LoginViewModel.State.NotAuthenticated -> NotAuthenticatedView( + onLoginButtonPressed = { + onLoginButtonPressed(authActivityLauncher) + }, + ) + } } - } - } -} - -@Composable -private fun ContentView( - state: LoginViewModel.State, - onLogoutButtonPressed: () -> Unit, - onFinishButtonPressed: () -> Unit, - onLoginButtonPressed: () -> Unit, -) { - when(state) { - is LoginViewModel.State.Authenticated -> AuthenticatedView( - currentUser = state.user, - onLogoutButtonPressed = onLogoutButtonPressed, - onFinishButtonPressed = onFinishButtonPressed, - ) - LoginViewModel.State.Loading -> LoadingView() - LoginViewModel.State.NotAuthenticated -> NotAuthenticatedView( - onLoginButtonPressed = onLoginButtonPressed, - ) - } + }, + ) } @Composable @@ -263,24 +260,19 @@ private fun AuthenticatedView( } } -@Composable -private fun LoadingView() { - Box { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - ) - } -} - @Preview(name = "Loading preview", showSystemUi = true) @Composable private fun LoadingPreview() { AppTheme { - ContentView( - state = LoginViewModel.State.Loading, + LoginView( + stateFlow = MutableStateFlow(LoginViewModel.State.Loading), + eventFlow = MutableSharedFlow(), + navigateUp = {}, onLogoutButtonPressed = {}, onFinishButtonPressed = {}, onLoginButtonPressed = {}, + onAuthActivityResult = {}, + finish = {}, ) } } @@ -289,17 +281,21 @@ private fun LoadingPreview() { @Composable private fun AuthenticatedPreview() { AppTheme { - ContentView( - state = LoginViewModel.State.Authenticated( + LoginView( + stateFlow = MutableStateFlow(LoginViewModel.State.Authenticated( user = CurrentUser( id = "", email = "test@login.com", token = "", ) - ), + )), + eventFlow = MutableSharedFlow(), + navigateUp = {}, onLogoutButtonPressed = {}, onFinishButtonPressed = {}, onLoginButtonPressed = {}, + onAuthActivityResult = {}, + finish = {}, ) } } @@ -308,11 +304,15 @@ private fun AuthenticatedPreview() { @Composable private fun NotAuthenticatedPreview() { AppTheme { - ContentView( - state = LoginViewModel.State.NotAuthenticated, + LoginView( + stateFlow = MutableStateFlow(LoginViewModel.State.NotAuthenticated), + eventFlow = MutableSharedFlow(), + navigateUp = {}, onLogoutButtonPressed = {}, onFinishButtonPressed = {}, onLoginButtonPressed = {}, + onAuthActivityResult = {}, + finish = {}, ) } } \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/login/LoginViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/login/LoginViewModel.kt similarity index 74% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/login/LoginViewModel.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/login/LoginViewModel.kt index 8570d798..4e604bd6 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/login/LoginViewModel.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/login/LoginViewModel.kt @@ -14,17 +14,20 @@ * limitations under the License. */ -package com.benoitletondor.easybudgetapp.view.main.login +package com.benoitletondor.easybudgetapp.view.login -import android.app.Activity import android.content.Intent -import androidx.lifecycle.SavedStateHandle +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.result.ActivityResult import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.benoitletondor.easybudgetapp.auth.Auth import com.benoitletondor.easybudgetapp.auth.AuthState import com.benoitletondor.easybudgetapp.auth.CurrentUser import com.benoitletondor.easybudgetapp.helper.MutableLiveFlow +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -33,16 +36,12 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject -@HiltViewModel -class LoginViewModel @Inject constructor( +@HiltViewModel(assistedFactory = LoginViewModelFactory::class) +class LoginViewModel @AssistedInject constructor( private val auth: Auth, - savedStateHandle: SavedStateHandle, + @Assisted private val shouldDismissAfterAuth: Boolean, ) : ViewModel() { - private val shouldDismissAfterAuth = savedStateHandle.get(LoginActivity.SHOULD_DISMISS_AFTER_AUTH_EXTRA) - ?: throw IllegalStateException("Missing SHOULD_DISMISS_AFTER_AUTH_EXTRA extra") - private val eventMutableFlow = MutableLiveFlow() val eventFlow: Flow = eventMutableFlow @@ -61,12 +60,12 @@ class LoginViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) - fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - auth.handleActivityResult(requestCode, resultCode, data) + fun handleAuthActivityResult(resultCode: Int, data: Intent?) { + auth.handleActivityResult(resultCode, data) } - fun onAuthenticatedButtonClicked(activity: Activity) { - auth.startAuthentication(activity) + fun onAuthenticatedButtonClicked(launcher: ManagedActivityResultLauncher) { + auth.startAuthentication(launcher) } fun onLogoutButtonClicked() { @@ -88,4 +87,9 @@ class LoginViewModel @Inject constructor( sealed class Event { data object Finish : Event() } +} + +@AssistedFactory +interface LoginViewModelFactory { + fun create(shouldDismissAfterAuth: Boolean): LoginViewModel } \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/FloatingActionButtonBehavior.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/FloatingActionButtonBehavior.kt deleted file mode 100644 index 80207977..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/FloatingActionButtonBehavior.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.main - -import android.content.Context -import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.snackbar.Snackbar -import android.util.AttributeSet -import android.view.View -import android.widget.LinearLayout - -import kotlin.math.min - -/** - * Behavior of FAB for the floating action menu reacting to Snackbar - * - * @author Benoit LETONDOR - */ -@Suppress("UNUSED_PARAMETER") -class FloatingActionButtonBehavior(context: Context, attrs: AttributeSet) : CoordinatorLayout.Behavior() { - - override fun layoutDependsOn(parent: CoordinatorLayout, child: LinearLayout, dependency: View): Boolean { - return dependency is Snackbar.SnackbarLayout - } - - override fun onDependentViewChanged(parent: CoordinatorLayout, child: LinearLayout, dependency: View): Boolean { - val translationY = min(0f, dependency.translationY - dependency.height) - child.translationY = translationY - return true - } - - override fun onDependentViewRemoved( - parent: CoordinatorLayout, - child: LinearLayout, - dependency: View - ) { - super.onDependentViewRemoved(parent, child, dependency) - child.translationY = 0f - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/MainActivity.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/MainActivity.kt deleted file mode 100644 index 2d195341..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/MainActivity.kt +++ /dev/null @@ -1,427 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.main - -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import androidx.activity.viewModels -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Text -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.core.app.ActivityCompat -import androidx.core.view.MenuProvider -import androidx.core.view.isVisible -import androidx.fragment.app.commit -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.withStarted -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.databinding.ActivityMainBinding -import com.benoitletondor.easybudgetapp.helper.* -import com.benoitletondor.easybudgetapp.parameters.* -import com.benoitletondor.easybudgetapp.theme.AppTheme -import com.benoitletondor.easybudgetapp.view.expenseedit.ExpenseEditActivity -import com.benoitletondor.easybudgetapp.view.main.account.AccountFragment -import com.benoitletondor.easybudgetapp.view.main.accountselector.AccountSelectorFragment -import com.benoitletondor.easybudgetapp.view.main.loading.LoadingFragment -import com.benoitletondor.easybudgetapp.view.recurringexpenseadd.RecurringExpenseEditActivity -import com.benoitletondor.easybudgetapp.view.report.base.MonthlyReportBaseActivity -import com.benoitletondor.easybudgetapp.view.settings.SettingsActivity -import com.benoitletondor.easybudgetapp.view.settings.SettingsActivity.Companion.SHOW_BACKUP_INTENT_KEY -import com.benoitletondor.easybudgetapp.view.welcome.WelcomeActivity -import com.benoitletondor.easybudgetapp.view.welcome.getOnboardingStep -import dagger.hilt.android.AndroidEntryPoint -import java.time.LocalDate -import javax.inject.Inject - -/** - * Main activity containing Calendar and List of expenses - * - * @author Benoit LETONDOR - */ -@AndroidEntryPoint -class MainActivity : BaseActivity(), MenuProvider { - - private val viewModel: MainViewModel by viewModels() - - @Inject - lateinit var parameters: Parameters - -// ------------------------------------------> - - override fun createBinding(): ActivityMainBinding = ActivityMainBinding.inflate(layoutInflater) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // Launch welcome screen if needed - if (parameters.getOnboardingStep() != WelcomeActivity.STEP_COMPLETED) { - val startIntent = Intent(this, WelcomeActivity::class.java) - ActivityCompat.startActivityForResult(this, startIntent, WELCOME_SCREEN_ACTIVITY_CODE, null) - } - - setSupportActionBar(binding.toolbar) - addMenuProvider(this) - - collectViewModelEvents() - - binding.mainComposeView.setContent { - val selectedAccount by viewModel.accountSelectionFlow.collectAsState() - val hasPendingInvitations by viewModel.hasPendingInvitationsFlow.collectAsState() - - AppTheme { - if (selectedAccount is MainViewModel.SelectedAccount.Selected) { - Box( - modifier = Modifier - .fillMaxWidth() - .background(colorResource(R.color.status_bar_color)) - .padding(bottom = 8.dp) - .clickable( - onClick = viewModel::onAccountTapped, - ) - .padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 10.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Box( - modifier = Modifier.weight(1f), - ) { - when(val account = selectedAccount) { - MainViewModel.SelectedAccount.Loading -> Unit /* Nothing to display when loading */ - is MainViewModel.SelectedAccount.Selected -> { - Row( - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.main_account_name) + " ", - fontWeight = FontWeight.SemiBold, - color = colorResource(R.color.action_bar_text_color), - ) - - Text( - modifier = Modifier.fillMaxWidth(), - text = when(account) { - MainViewModel.SelectedAccount.Selected.Offline -> stringResource(R.string.main_account_default_name) - is MainViewModel.SelectedAccount.Selected.Online -> stringResource(R.string.main_account_online_name, account.name) - }, - maxLines = 1, - color = colorResource(R.color.action_bar_text_color), - overflow = TextOverflow.Ellipsis, - ) - } - } - } - } - - if (hasPendingInvitations && selectedAccount is MainViewModel.SelectedAccount.Selected) { - Box( - modifier = Modifier.padding(start = 16.dp, end = 6.dp), - ){ - Image( - painter = painterResource(id = R.drawable.ic_baseline_notifications_24), - colorFilter = ColorFilter.tint(colorResource(R.color.action_bar_text_color)), - contentDescription = stringResource(R.string.account_pending_invitation_description), - ) - Box( - modifier = Modifier - .size(7.dp) - .clip(CircleShape) - .background(colorResource(R.color.budget_red)) - .align(Alignment.TopEnd) - ) - } - } else { - Image( - painter = painterResource(id = R.drawable.ic_baseline_arrow_drop_down_24), - colorFilter = ColorFilter.tint(colorResource(R.color.action_bar_text_color)), - contentDescription = null, - modifier = Modifier.padding(start = 10.dp), - ) - } - } - - } - } - } - } - } - - @Deprecated("This method has been deprecated in favor of using the Activity Result API\n which brings increased type safety via an {@link ActivityResultContract} and the prebuilt\n contracts for common intents available in\n {@link androidx.activity.result.contract.ActivityResultContracts}, provides hooks for\n testing, and allow receiving results in separate, testable classes independent from your\n activity. Use\n {@link #registerForActivityResult(ActivityResultContract, ActivityResultCallback)}\n with the appropriate {@link ActivityResultContract} and handling the result in the\n {@link ActivityResultCallback#onActivityResult(Object) callback}.") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - - if (requestCode == WELCOME_SCREEN_ACTIVITY_CODE) { - if (resultCode == RESULT_OK) { - viewModel.onWelcomeScreenFinished() - } else if (resultCode == RESULT_CANCELED) { - finish() // Finish activity if welcome screen is finish via back button - } - } else { - for (fragment in supportFragmentManager.fragments) { - fragment.onActivityResult(requestCode, resultCode, data) - } - } - } - - private fun collectViewModelEvents() { - lifecycleScope.launchCollect(viewModel.premiumStatusFlow) { - invalidateOptionsMenu() - } - - lifecycleScope.launchCollect(viewModel.openPremiumEventFlow) { - val startIntent = Intent(this, SettingsActivity::class.java) - startIntent.putExtra(SettingsActivity.SHOW_PREMIUM_INTENT_KEY, true) - ActivityCompat.startActivity(this, startIntent, null) - } - - lifecycleScope.launchCollect(viewModel.accountSelectionFlow) { selectedAccount -> - invalidateOptionsMenu() - - withStarted { - when(selectedAccount) { - MainViewModel.SelectedAccount.Loading -> { - supportFragmentManager.commit { - replace(R.id.mainFragmentContainer, LoadingFragment()) - } - } - is MainViewModel.SelectedAccount.Selected -> { - performIntentActionIfAny() - - supportFragmentManager.commit { - replace(R.id.mainFragmentContainer, AccountFragment.newInstance(selectedAccount)) - } - } - } - } - } - - lifecycleScope.launchCollect(viewModel.eventFlow) { event -> - when(event) { - MainViewModel.Event.ShowAccountSelect -> AccountSelectorFragment().show(supportFragmentManager, "accountSelector") - } - } - } - - private fun performIntentActionIfAny() { - if (intent != null) { - openSettingsIfNeeded(intent) - openMonthlyReportIfNeeded(intent) - openPremiumIfNeeded(intent) - openAddExpenseIfNeeded(intent) - openAddRecurringExpenseIfNeeded(intent) - openSettingsForBackupIfNeeded(intent) - openAccountsTrayIfNeeded(intent) - intent = null - } - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - - this.intent = intent - - (viewModel.accountSelectionFlow.value as? MainViewModel.SelectedAccount.Selected)?.let { - performIntentActionIfAny() - } - } - - fun onAccountSelectedFromBottomSheet(account: MainViewModel.SelectedAccount.Selected) { - viewModel.onAccountSelected(account) - } - -// ------------------------------------------> - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - // Inflate the menu; this adds items to the action bar if it is present. - menuInflater.inflate(R.menu.menu_main, menu) - - if (viewModel.shouldShowMenuButtons()) { - // Remove monthly report for non premium users - if ( viewModel.showPremiumMenuButtons() ) { - menu.removeItem(R.id.action_become_premium) - - if ( !parameters.hasUserSawMonthlyReportHint() ) { - binding.monthlyReportHint.isVisible = true - - binding.monthlyReportHintButton.setOnClickListener { - binding.monthlyReportHint.isVisible = false - parameters.setUserSawMonthlyReportHint() - } - } - } - } else { - menu.removeItem(R.id.action_become_premium) - } - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_settings -> { - val startIntent = Intent(this, SettingsActivity::class.java) - this@MainActivity.startActivity(startIntent) - - true - } - R.id.action_become_premium -> { - viewModel.onBecomePremiumButtonPressed() - - true - } - else -> false - } - } - -// ------------------------------------------> - - /** - * Open the settings activity if the given intent contains the [.INTENT_REDIRECT_TO_SETTINGS_EXTRA] - * extra. - */ - private fun openSettingsIfNeeded(intent: Intent) { - if (intent.getBooleanExtra(INTENT_REDIRECT_TO_SETTINGS_EXTRA, false)) { - val startIntent = Intent(this, SettingsActivity::class.java) - this@MainActivity.startActivity(startIntent) - } - } - - /** - * Open the settings activity to display backup options if the given intent contains the - * [.INTENT_REDIRECT_TO_SETTINGS_FOR_BACKUP_EXTRA] extra. - */ - private fun openSettingsForBackupIfNeeded(intent: Intent) { - if( intent.getBooleanExtra(INTENT_REDIRECT_TO_SETTINGS_FOR_BACKUP_EXTRA, false) ) { - val startIntent = Intent(this, SettingsActivity::class.java).apply { - putExtra(SHOW_BACKUP_INTENT_KEY, true) - } - this@MainActivity.startActivity(startIntent) - } - } - - private fun openAccountsTrayIfNeeded(intent: Intent) { - if( intent.getBooleanExtra(INTENT_OPEN_ACCOUNTS_TRAY_EXTRA, false) ) { - AccountSelectorFragment().show(supportFragmentManager, "accountSelector") - } - } - - /** - * Open the monthly report activity if the given intent contains the monthly uri part. - * - * @param intent - */ - private fun openMonthlyReportIfNeeded(intent: Intent) { - try { - val data = intent.data - if (data != null && "true" == data.getQueryParameter("monthly")) { - val startIntent = Intent(this, MonthlyReportBaseActivity::class.java) - startIntent.putExtra(MonthlyReportBaseActivity.FROM_NOTIFICATION_EXTRA, true) - ActivityCompat.startActivity(this@MainActivity, startIntent, null) - } - } catch (e: Exception) { - Logger.error("Error while opening report activity", e) - } - } - - /** - * Open the premium screen if the given intent contains the [.INTENT_REDIRECT_TO_PREMIUM_EXTRA] - * extra. - * - * @param intent - */ - private fun openPremiumIfNeeded(intent: Intent) { - if (intent.getBooleanExtra(INTENT_REDIRECT_TO_PREMIUM_EXTRA, false)) { - val startIntent = Intent(this, SettingsActivity::class.java) - startIntent.putExtra(SettingsActivity.SHOW_PREMIUM_INTENT_KEY, true) - - this.startActivity(startIntent) - } - } - - /** - * Open the add expense screen if the given intent contains the [.INTENT_SHOW_ADD_EXPENSE] - * extra. - * - * @param intent - */ - private fun openAddExpenseIfNeeded(intent: Intent) { - if (intent.getBooleanExtra(INTENT_SHOW_ADD_EXPENSE, false)) { - val startIntent = ExpenseEditActivity.newIntent( - context = this, - date = LocalDate.now(), - editedExpense = null, - ) - - startActivity(startIntent) - } - } - - /** - * Open the add recurring expense screen if the given intent contains the [.INTENT_SHOW_ADD_RECURRING_EXPENSE] - * extra. - * - * @param intent - */ - private fun openAddRecurringExpenseIfNeeded(intent: Intent) { - if (intent.getBooleanExtra(INTENT_SHOW_ADD_RECURRING_EXPENSE, false)) { - val startIntent = RecurringExpenseEditActivity.newIntent( - context = this, - startDate = LocalDate.now(), - editedExpense = null, - ) - - startActivity(startIntent) - } - } - - companion object { - const val WELCOME_SCREEN_ACTIVITY_CODE = 103 - const val INTENT_EXPENSE_DELETED = "intent.expense.deleted" - const val INTENT_RECURRING_EXPENSE_DELETED = "intent.expense.monthly.deleted" - const val INTENT_SHOW_WELCOME_SCREEN = "intent.welcomscreen.show" - const val INTENT_SHOW_ADD_EXPENSE = "intent.addexpense.show" - const val INTENT_SHOW_ADD_RECURRING_EXPENSE = "intent.addrecurringexpense.show" - const val INTENT_SHOW_CHECKED_BALANCE_CHANGED = "intent.showcheckedbalance.changed" - const val INTENT_LOW_MONEY_WARNING_THRESHOLD_CHANGED = "intent.lowmoneywarningthreshold.changed" - - const val INTENT_REDIRECT_TO_PREMIUM_EXTRA = "intent.extra.premiumshow" - const val INTENT_REDIRECT_TO_SETTINGS_EXTRA = "intent.extra.redirecttosettings" - const val INTENT_REDIRECT_TO_SETTINGS_FOR_BACKUP_EXTRA = "intent.extra.redirecttosettingsforbackup" - const val INTENT_OPEN_ACCOUNTS_TRAY_EXTRA = "intent.extra.openaccountstray" - } -} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/MainView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/MainView.kt new file mode 100644 index 00000000..a7ad5e39 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/MainView.kt @@ -0,0 +1,941 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.main + +import android.app.ProgressDialog +import android.content.res.Configuration +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.EditText +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.compose.AppTheme +import com.benoitletondor.easybudgetapp.db.RestoreAction +import com.benoitletondor.easybudgetapp.helper.CurrencyHelper +import com.benoitletondor.easybudgetapp.helper.Logger +import com.benoitletondor.easybudgetapp.helper.launchCollect +import com.benoitletondor.easybudgetapp.helper.preventUnsupportedInputForDecimals +import com.benoitletondor.easybudgetapp.injection.AppModule +import com.benoitletondor.easybudgetapp.model.AssociatedRecurringExpense +import com.benoitletondor.easybudgetapp.model.DataForDay +import com.benoitletondor.easybudgetapp.model.DataForMonth +import com.benoitletondor.easybudgetapp.model.Expense +import com.benoitletondor.easybudgetapp.model.RecurringExpense +import com.benoitletondor.easybudgetapp.model.RecurringExpenseDeleteType +import com.benoitletondor.easybudgetapp.model.RecurringExpenseType +import com.benoitletondor.easybudgetapp.view.main.subviews.accountselector.AccountSelectorView +import com.benoitletondor.easybudgetapp.view.main.subviews.FABMenuOverlay +import com.benoitletondor.easybudgetapp.view.main.subviews.MainViewContent +import com.benoitletondor.easybudgetapp.view.main.subviews.MainViewTopBar +import com.benoitletondor.easybudgetapp.view.main.subviews.MonthlyReportHint +import com.benoitletondor.easybudgetapp.view.onboarding.OnboardingResult +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.kizitonwose.calendar.core.atStartOfMonth +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import java.util.Currency + +@Serializable +object MainDestination + +@Composable +fun MainView( + viewModel: MainViewModel = hiltViewModel(), + openAddExpenseScreenLiveFlow: Flow, + openAddRecurringExpenseScreenLiveFlow: Flow, + openMonthlyReportScreenFromNotificationFlow: Flow, + navigateToOnboarding: () -> Unit, + onboardingResultFlow: Flow, + closeApp: () -> Unit, + navigateToPremium: (startOnPro: Boolean) -> Unit, + navigateToMonthlyReport: (fromNotification: Boolean) -> Unit, + navigateToManageAccount: (account: MainViewModel.SelectedAccount.Selected.Online) -> Unit, + navigateToSettings: () -> Unit, + navigateToLogin: (shouldDismissAfterAuth: Boolean) -> Unit, + navigateToCreateAccount: () -> Unit, + navigateToAddExpense: (LocalDate, Expense?) -> Unit, + navigateToAddRecurringExpense: (LocalDate, Expense?) -> Unit, +) { + MainView( + selectedAccountFlow = viewModel.accountSelectionFlow, + dbStateFlow = viewModel.dbAvailableFlow, + eventFlow = viewModel.eventFlow, + showMonthlyReportHintFlow = viewModel.showMonthlyReportHintFlow, + openAddExpenseScreenLiveFlow = openAddExpenseScreenLiveFlow, + openAddRecurringExpenseScreenLiveFlow = openAddRecurringExpenseScreenLiveFlow, + openMonthlyReportScreenFromNotificationFlow = openMonthlyReportScreenFromNotificationFlow, + forceRefreshDataFlow = viewModel.forceRefreshFlow, + firstDayOfWeekFlow = viewModel.firstDayOfWeekFlow, + includeCheckedBalanceFlow = viewModel.includeCheckedBalanceFlow, + getDataForMonth = viewModel::getDataForMonth, + selectedDateFlow = viewModel.selectedDateFlow, + lowMoneyAmountWarningFlow = viewModel.lowMoneyAmountWarningFlow, + goBackToCurrentMonthEventFlow = viewModel.eventFlow + .filterIsInstance() + .map { /* No-op to produce Unit */ }, + appInitDate = viewModel.appInitDate, + showActionButtonsFlow = viewModel.showMenuActionButtonsFlow, + showPremiumRelatedButtonsFlow = viewModel.showPremiumRelatedButtonsFlow, + showManageAccountButtonFlow = viewModel.showManageAccountMenuItemFlow, + showGoBackToCurrentMonthButtonFlow = viewModel.showGoToCurrentMonthButtonStateFlow, + hasPendingInvitationsFlow = viewModel.hasPendingInvitationsFlow, + userCurrencyFlow = viewModel.userCurrencyFlow, + recurringExpenseDeletionProgressFlow = viewModel.recurringExpenseDeletionProgressStateFlow, + recurringExpenseRestoreProgressFlow = viewModel.recurringExpenseRestoreProgressStateFlow, + dayDataFlow = viewModel.selectedDateDataFlow, + showExpensesCheckBoxFlow = viewModel.showExpensesCheckBoxFlow, + onboardingResultFlow = onboardingResultFlow, + shouldNavigateToOnboarding = viewModel.shouldNavigateToOnboarding, + onSettingsButtonPressed = viewModel::onSettingsButtonPressed, + onAdjustCurrentBalanceButtonPressed = viewModel::onAdjustCurrentBalanceClicked, + onTickAllPastEntriesButtonPressed = viewModel::onCheckAllPastEntriesPressed, + onManageAccountButtonPressed = viewModel::onManageAccountButtonPressed, + onDiscoverPremiumButtonPressed = viewModel::onDiscoverPremiumButtonPressed, + onMonthlyReportButtonPressed = viewModel::onMonthlyReportButtonPressed, + onGoBackToCurrentMonthButtonPressed = viewModel::onGoBackToCurrentMonthButtonPressed, + onCurrentAccountTapped = viewModel::onCurrentAccountTapped, + onMonthChanged = viewModel::onMonthChanged, + onDateClicked = viewModel::onSelectDate, + onDateLongClicked = viewModel::onDateLongClicked, + onRetryDBLoadingButtonPressed = viewModel::onRetryLoadingDBButtonPressed, + onAccountSelected = viewModel::onAccountSelected, + onExpenseDeletionCancelled = viewModel::onExpenseDeletionCancelled, + onCurrentBalanceEditedCancelled = viewModel::onCurrentBalanceEditedCancelled, + onRestoreRecurringExpenseClicked = viewModel::onRestoreRecurringExpenseClicked, + onCheckAllPastEntriesConfirmPressed = viewModel::onCheckAllPastEntriesConfirmPressed, + onNewBalanceSelected = viewModel::onNewBalanceSelected, + onAddRecurringEntryPressed = viewModel::onAddRecurringEntryPressed, + onAddEntryPressed = viewModel::onAddEntryPressed, + onExpenseCheckedChange = viewModel::onExpenseChecked, + onExpensePressed = viewModel::onExpensePressed, + onExpenseLongPressed = viewModel::onExpenseLongPressed, + onDeleteRecurringExpenseClicked = viewModel::onDeleteRecurringExpenseClicked, + onDeleteExpenseClicked = viewModel::onDeleteExpenseClicked, + onEditExpensePressed = viewModel::onEditExpensePressed, + onEditRecurringExpenseOccurrenceAndFollowingOnesPressed = viewModel::onEditRecurringExpenseOccurenceAndFollowingOnesPressed, + onEditRecurringExpenseOccurrencePressed = viewModel::onEditRecurringExpenseOccurencePressed, + navigateToOnboarding = navigateToOnboarding, + onOnboardingResult = viewModel::onOnboardingResult, + closeApp = closeApp, + navigateToPremium = navigateToPremium, + navigateToMonthlyReport = navigateToMonthlyReport, + navigateToManageAccount = navigateToManageAccount, + navigateToSettings = navigateToSettings, + navigateToLogin = navigateToLogin, + navigateToCreateAccount = navigateToCreateAccount, + navigateToAddExpense = navigateToAddExpense, + navigateToAddRecurringExpense = navigateToAddRecurringExpense, + onMonthlyReportHintDismissed = viewModel::onMonthlyReportHintDismissed, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MainView( + selectedAccountFlow: StateFlow, + dbStateFlow: StateFlow, + eventFlow: Flow, + showMonthlyReportHintFlow: StateFlow, + openAddExpenseScreenLiveFlow: Flow, + openAddRecurringExpenseScreenLiveFlow: Flow, + openMonthlyReportScreenFromNotificationFlow: Flow, + forceRefreshDataFlow: Flow, + firstDayOfWeekFlow: StateFlow, + includeCheckedBalanceFlow: StateFlow, + getDataForMonth: suspend (YearMonth) -> DataForMonth, + selectedDateFlow: StateFlow, + lowMoneyAmountWarningFlow: StateFlow, + goBackToCurrentMonthEventFlow: Flow, + appInitDate: LocalDate, + showActionButtonsFlow: StateFlow, + showPremiumRelatedButtonsFlow: StateFlow, + showManageAccountButtonFlow: StateFlow, + showGoBackToCurrentMonthButtonFlow: StateFlow, + hasPendingInvitationsFlow: StateFlow, + userCurrencyFlow: StateFlow, + recurringExpenseDeletionProgressFlow: StateFlow, + recurringExpenseRestoreProgressFlow: StateFlow, + dayDataFlow: StateFlow, + showExpensesCheckBoxFlow: StateFlow, + onboardingResultFlow: Flow, + shouldNavigateToOnboarding: Boolean, + onSettingsButtonPressed: () -> Unit, + onAdjustCurrentBalanceButtonPressed: () -> Unit, + onTickAllPastEntriesButtonPressed: () -> Unit, + onManageAccountButtonPressed: () -> Unit, + onDiscoverPremiumButtonPressed: () -> Unit, + onMonthlyReportButtonPressed: () -> Unit, + onGoBackToCurrentMonthButtonPressed: () -> Unit, + onCurrentAccountTapped: () -> Unit, + onMonthChanged: (YearMonth) -> Unit, + onDateClicked: (LocalDate) -> Unit, + onDateLongClicked: (LocalDate) -> Unit, + onRetryDBLoadingButtonPressed: () -> Unit, + onAccountSelected: (MainViewModel.SelectedAccount.Selected) -> Unit, + onExpenseDeletionCancelled: (RestoreAction) -> Unit, + onCurrentBalanceEditedCancelled: (Expense, Double) -> Unit, + onRestoreRecurringExpenseClicked: (RecurringExpense, RestoreAction) -> Unit, + onCheckAllPastEntriesConfirmPressed: () -> Unit, + onNewBalanceSelected: (Double, String) -> Unit, + onAddRecurringEntryPressed: () -> Unit, + onAddEntryPressed: () -> Unit, + onExpenseCheckedChange: (Expense, Boolean) -> Unit, + onExpensePressed: (Expense) -> Unit, + onExpenseLongPressed: (Expense) -> Unit, + onDeleteRecurringExpenseClicked: (Expense, RecurringExpenseDeleteType) -> Unit, + onDeleteExpenseClicked: (Expense) -> Unit, + onEditExpensePressed: (Expense) -> Unit, + onEditRecurringExpenseOccurrenceAndFollowingOnesPressed: (Expense) -> Unit, + onEditRecurringExpenseOccurrencePressed: (Expense) -> Unit, + navigateToOnboarding: () -> Unit, + onOnboardingResult: (OnboardingResult) -> Unit, + closeApp: () -> Unit, + navigateToPremium: (startOnPro: Boolean) -> Unit, + navigateToMonthlyReport: (fromNotification: Boolean) -> Unit, + navigateToManageAccount: (MainViewModel.SelectedAccount.Selected.Online) -> Unit, + navigateToSettings: () -> Unit, + navigateToLogin: (shouldDismissAfterAuth: Boolean) -> Unit, + navigateToCreateAccount: () -> Unit, + navigateToAddExpense: (LocalDate, Expense?) -> Unit, + navigateToAddRecurringExpense: (LocalDate, Expense?) -> Unit, + onMonthlyReportHintDismissed: () -> Unit, +) { + var showAccountSelectorModal by rememberSaveable { mutableStateOf(false) } + val accountSelectorModalSheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + var showFABMenu by remember { mutableStateOf(false) } + + val context = LocalContext.current + + LaunchedEffect(key1 = "startOnboarding") { + if (shouldNavigateToOnboarding) { + navigateToOnboarding() + } + } + + LaunchedEffect(key1 = "openAddExpenseScreen") { + launch { + dbStateFlow + .flatMapLatest { state -> + if (state is MainViewModel.DBState.Loaded) { + return@flatMapLatest openAddExpenseScreenLiveFlow + } else { + return@flatMapLatest flow { } + } + } + .collect { + navigateToAddExpense(LocalDate.now(), null) + } + } + } + + LaunchedEffect(key1 = "openAddRecurringExpenseScreen") { + launch { + dbStateFlow + .flatMapLatest { state -> + if (state is MainViewModel.DBState.Loaded) { + return@flatMapLatest openAddRecurringExpenseScreenLiveFlow + } else { + return@flatMapLatest flow { } + } + } + .collect { + navigateToAddRecurringExpense(LocalDate.now(), null) + } + } + } + + LaunchedEffect(key1 = "openMonthlyReportScreen") { + launch { + dbStateFlow + .flatMapLatest { state -> + if (state is MainViewModel.DBState.Loaded) { + return@flatMapLatest openMonthlyReportScreenFromNotificationFlow + } else { + return@flatMapLatest flow { } + } + } + .collect { + navigateToMonthlyReport(true) + } + } + } + + LaunchedEffect(key1 = "eventsListener") { + launchCollect(eventFlow) { event -> + when(event) { + is MainViewModel.Event.CheckAllPastEntriesError -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.check_all_past_expences_error_title) + .setMessage( + context.getString( + R.string.check_all_past_expences_error_message, + event.error.localizedMessage, + ) + ) + .setNegativeButton(R.string.ok) { dialog2, _ -> dialog2.dismiss() } + .show() + } + is MainViewModel.Event.CurrentBalanceEditionError -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.adjust_balance_error_title) + .setMessage(R.string.adjust_balance_error_message) + .setNegativeButton(R.string.ok) { dialog1, _ -> dialog1.dismiss() } + .show() + } + is MainViewModel.Event.CurrentBalanceEditionSuccess -> { + val (expense, diff, newBalance) = event.data + + coroutineScope.launch { + val result = snackbarHostState.showSnackbar( + message = context.getString( + R.string.adjust_balance_snackbar_text, + CurrencyHelper.getFormattedCurrencyString( + currency = userCurrencyFlow.value, + amount = newBalance, + ) + ), + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Long, + ) + + if (result === SnackbarResult.ActionPerformed) { + onCurrentBalanceEditedCancelled(expense, diff) + } + } + } + is MainViewModel.Event.CurrentBalanceRestorationError -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.adjust_balance_error_title) + .setMessage(R.string.adjust_balance_error_message) + .setNegativeButton(R.string.ok) { dialog1, _ -> dialog1.dismiss() } + .show() + } + is MainViewModel.Event.ExpenseCheckingError -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.expense_check_error_title) + .setMessage( + context.getString( + R.string.expense_check_error_message, + event.error.localizedMessage + ) + ) + .setNegativeButton(R.string.ok) { dialog2, _ -> dialog2.dismiss() } + .show() + } + is MainViewModel.Event.ExpenseDeletionError -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.expense_delete_error_title) + .setMessage(R.string.expense_delete_error_message) + .setNegativeButton(R.string.ok) { dialog, _ -> dialog.dismiss() } + .show() + } + is MainViewModel.Event.ExpenseDeletionSuccess -> { + val (deletedExpense, restoreAction) = event.data + + coroutineScope.launch { + val result = snackbarHostState.showSnackbar( + message = context.getString(if (deletedExpense.isRevenue()) R.string.income_delete_snackbar_text else R.string.expense_delete_snackbar_text), + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Long, + ) + + if (result === SnackbarResult.ActionPerformed) { + onExpenseDeletionCancelled(restoreAction) + } + } + } + MainViewModel.Event.GoBackToCurrentMonth -> Unit /* No-op */ + is MainViewModel.Event.OpenAddExpense -> { + navigateToAddExpense(event.date, null) + } + is MainViewModel.Event.OpenAddRecurringExpense -> { + navigateToAddRecurringExpense(event.date, null) + } + is MainViewModel.Event.OpenManageAccount -> navigateToManageAccount(event.account) + MainViewModel.Event.OpenMonthlyReport -> navigateToMonthlyReport(false) + MainViewModel.Event.OpenPremium -> navigateToPremium(false) + is MainViewModel.Event.RecurringExpenseDeletionResult -> { + when(event.data) { + is MainViewModel.RecurringExpenseDeletionEvent.ErrorCantDeleteBeforeFirstOccurrence -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.recurring_expense_delete_first_error_title) + .setMessage(R.string.recurring_expense_delete_first_error_message) + .setNegativeButton(R.string.ok, null) + .show() + } + is MainViewModel.RecurringExpenseDeletionEvent.ErrorIO -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.recurring_expense_delete_error_title) + .setMessage(R.string.recurring_expense_delete_error_message) + .setNegativeButton(R.string.ok) { dialog, _ -> dialog.dismiss() } + .show() + } + is MainViewModel.RecurringExpenseDeletionEvent.ErrorRecurringExpenseDeleteNotAssociated -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.recurring_expense_delete_error_title) + .setMessage(R.string.recurring_expense_delete_error_message) + .setNegativeButton(R.string.ok) { dialog, _ -> dialog.dismiss() } + .show() + } + is MainViewModel.RecurringExpenseDeletionEvent.Success -> { + coroutineScope.launch { + val result = snackbarHostState.showSnackbar( + message = context.getString(R.string.recurring_expense_delete_success_message), + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Long, + ) + + if (result === SnackbarResult.ActionPerformed) { + onRestoreRecurringExpenseClicked( + event.data.recurringExpense, + event.data.restoreAction, + ) + } + } + } + } + } + is MainViewModel.Event.RecurringExpenseRestoreResult -> { + when(event.data) { + is MainViewModel.RecurringExpenseRestoreEvent.ErrorIO -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.recurring_expense_restore_error_title) + .setMessage(context.getString(R.string.recurring_expense_restore_error_message)) + .setNegativeButton(R.string.ok) { dialog, _ -> dialog.dismiss() } + .show() + } + is MainViewModel.RecurringExpenseRestoreEvent.Success -> { + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = context.getString(R.string.recurring_expense_restored_success_message), + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Long, + ) + } + } + } + } + MainViewModel.Event.ShowAccountSelect -> { + showAccountSelectorModal = true + } + MainViewModel.Event.ShowConfirmCheckAllPastEntries -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.check_all_past_expences_title) + .setMessage(R.string.check_all_past_expences_message) + .setPositiveButton(R.string.check_all_past_expences_confirm_cta) { dialog2, _ -> + onCheckAllPastEntriesConfirmPressed() + dialog2.dismiss() + } + .setNegativeButton(android.R.string.cancel) { dialog2, _ -> dialog2.dismiss() } + .show() + } + MainViewModel.Event.ShowSettings -> { + navigateToSettings() + } + is MainViewModel.Event.StartCurrentBalanceEditor -> { + val dialogView = LayoutInflater.from(context).inflate(R.layout.dialog_adjust_balance, null) + val amountEditText = dialogView.findViewById(R.id.balance_amount) + amountEditText.setText( + if (event.currentBalance == 0.0) "0" else CurrencyHelper.getFormattedAmountValue( + event.currentBalance + ) + ) + amountEditText.preventUnsupportedInputForDecimals() + amountEditText.setSelection(amountEditText.text.length) // Put focus at the end of the text + + val builder = MaterialAlertDialogBuilder(context) + builder.setTitle(R.string.adjust_balance_title) + builder.setMessage(R.string.adjust_balance_message) + builder.setView(dialogView) + builder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + builder.setPositiveButton(R.string.ok) { dialog, _ -> + try { + val stringValue = amountEditText.text.toString() + if (stringValue.isNotBlank()) { + val newBalance = java.lang.Double.valueOf(stringValue) + onNewBalanceSelected( + newBalance, + context.getString(R.string.adjust_balance_expense_title) + ) + } + } catch (e: Exception) { + Logger.error("Error parsing new balance", e) + } + + dialog.dismiss() + } + + val dialog = builder.show() + + // Directly show keyboard when the dialog pops + amountEditText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> + // Check if the device doesn't have a physical keyboard + if (hasFocus && context.resources.configuration.keyboard == Configuration.KEYBOARD_NOKEYS) { + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + } + } + } + is MainViewModel.Event.ShowExpenseEditionOptions -> { + val expense = event.expense + if (expense.isRecurring()) { + val builder = MaterialAlertDialogBuilder(context) + builder.setTitle(if (expense.isRevenue()) R.string.dialog_edit_recurring_income_title else R.string.dialog_edit_recurring_expense_title) + builder.setItems(if (expense.isRevenue()) R.array.dialog_edit_recurring_income_choices else R.array.dialog_edit_recurring_expense_choices) { _, which -> + when (which) { + // Edit this one + 0 -> onEditRecurringExpenseOccurrencePressed(expense) + // Edit this one and following ones + 1 -> onEditRecurringExpenseOccurrenceAndFollowingOnesPressed(expense) + // Delete this one + 2 -> onDeleteRecurringExpenseClicked(expense, RecurringExpenseDeleteType.ONE) + // Delete from + 3 -> onDeleteRecurringExpenseClicked(expense, RecurringExpenseDeleteType.FROM) + // Delete up to + 4 -> onDeleteRecurringExpenseClicked(expense, RecurringExpenseDeleteType.TO) + // Delete all + 5 -> onDeleteRecurringExpenseClicked(expense, RecurringExpenseDeleteType.ALL) + } + } + builder.show() + } else { + val builder = MaterialAlertDialogBuilder(context) + builder.setTitle(if (expense.isRevenue()) R.string.dialog_edit_income_title else R.string.dialog_edit_expense_title) + builder.setItems(if (expense.isRevenue()) R.array.dialog_edit_income_choices else R.array.dialog_edit_expense_choices) { _, which -> + when (which) { + 0 // Edit expense + -> onEditExpensePressed(expense) + 1 // Delete + -> onDeleteExpenseClicked(expense) + } + } + builder.show() + } + } + is MainViewModel.Event.OpenEditExpense -> { + navigateToAddExpense(event.expense.date, event.expense) + } + is MainViewModel.Event.OpenEditRecurringExpenseOccurrence -> { + navigateToAddExpense(event.expense.date, event.expense) + } + is MainViewModel.Event.OpenEditRecurringExpenseOccurrenceAndFollowingOnes -> { + navigateToAddRecurringExpense(event.expense.date, event.expense) + } + MainViewModel.Event.StartOnboarding -> navigateToOnboarding() + MainViewModel.Event.CloseApp -> closeApp() + } + } + } + + LaunchedEffect(key1 = "recurringExpenseDeletionProgressDialog") { + var expenseDeletionDialog: ProgressDialog? = null + launchCollect(recurringExpenseDeletionProgressFlow) { state -> + when(state) { + is MainViewModel.RecurringExpenseDeleteProgressState.Deleting -> { + val dialog = ProgressDialog(context) + dialog.isIndeterminate = true + dialog.setTitle(R.string.recurring_expense_delete_loading_title) + dialog.setMessage(context.getString(R.string.recurring_expense_delete_loading_message)) + dialog.setCanceledOnTouchOutside(false) + dialog.setCancelable(false) + dialog.show() + + expenseDeletionDialog = dialog + } + MainViewModel.RecurringExpenseDeleteProgressState.Idle -> { + expenseDeletionDialog?.dismiss() + expenseDeletionDialog = null + } + } + } + } + + LaunchedEffect(key1 = "expenseRestorationProgressDialog") { + var expenseRestoreDialog: ProgressDialog? = null + launchCollect(recurringExpenseRestoreProgressFlow) { state -> + when(state) { + is MainViewModel.RecurringExpenseRestoreProgressState.Restoring -> { + val dialog = ProgressDialog(context) + dialog.isIndeterminate = true + dialog.setTitle(R.string.recurring_expense_restoring_loading_title) + dialog.setMessage(context.getString(R.string.recurring_expense_restoring_loading_message)) + dialog.setCanceledOnTouchOutside(false) + dialog.setCancelable(false) + dialog.show() + + expenseRestoreDialog = dialog + } + MainViewModel.RecurringExpenseRestoreProgressState.Idle -> { + expenseRestoreDialog?.dismiss() + expenseRestoreDialog = null + } + } + } + } + + LaunchedEffect(key1 = "onboardingResultListener") { + launchCollect(onboardingResultFlow) { result -> + onOnboardingResult(result) + } + } + + Scaffold( + topBar = { + MainViewTopBar( + showActionButtonsFlow = showActionButtonsFlow, + showPremiumRelatedButtonsFlow = showPremiumRelatedButtonsFlow, + showManageAccountButtonFlow = showManageAccountButtonFlow, + showGoBackToCurrentMonthButtonFlow = showGoBackToCurrentMonthButtonFlow, + onSettingsButtonPressed = onSettingsButtonPressed, + onAdjustCurrentBalanceButtonPressed = onAdjustCurrentBalanceButtonPressed, + onTickAllPastEntriesButtonPressed = onTickAllPastEntriesButtonPressed, + onManageAccountButtonPressed = onManageAccountButtonPressed, + onDiscoverPremiumButtonPressed = onDiscoverPremiumButtonPressed, + onMonthlyReportButtonPressed = onMonthlyReportButtonPressed, + onGoBackToCurrentMonthButtonPressed = onGoBackToCurrentMonthButtonPressed, + ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) { snackbarData -> + Snackbar( + snackbarData = snackbarData, + actionColor = colorResource(R.color.snackbar_action_undo), + ) + } + }, + floatingActionButton = { + FloatingActionButton( + onClick = { + showFABMenu = !showFABMenu + }, + containerColor = colorResource(R.color.home_fab_button_color), + contentColor = colorResource(R.color.white), + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_add_24), + contentDescription = stringResource(R.string.fab_add_expense), + ) + } + }, + content = { contentPadding -> + Box { + MainViewContent( + modifier = Modifier.padding( + top = contentPadding.calculateTopPadding(), + start = contentPadding.calculateStartPadding(LocalLayoutDirection.current), + end = contentPadding.calculateEndPadding(LocalLayoutDirection.current), + ), + selectedAccountFlow = selectedAccountFlow, + dbStateFlow = dbStateFlow, + hasPendingInvitationsFlow = hasPendingInvitationsFlow, + forceRefreshDataFlow = forceRefreshDataFlow, + firstDayOfWeekFlow = firstDayOfWeekFlow, + includeCheckedBalanceFlow = includeCheckedBalanceFlow, + getDataForMonth = getDataForMonth, + selectedDateFlow = selectedDateFlow, + lowMoneyAmountWarningFlow = lowMoneyAmountWarningFlow, + goBackToCurrentMonthEventFlow = goBackToCurrentMonthEventFlow, + dayDataFlow = dayDataFlow, + userCurrencyFlow = userCurrencyFlow, + showExpensesCheckBoxFlow = showExpensesCheckBoxFlow, + appInitDate = appInitDate, + onCurrentAccountTapped = onCurrentAccountTapped, + onMonthChanged = onMonthChanged, + onDateClicked = onDateClicked, + onDateLongClicked = onDateLongClicked, + onRetryDBLoadingButtonPressed = onRetryDBLoadingButtonPressed, + onExpenseCheckedChange = onExpenseCheckedChange, + onExpensePressed = onExpensePressed, + onExpenseLongPressed = onExpenseLongPressed, + ) + + val showMonthlyReportHint by showMonthlyReportHintFlow.collectAsState() + if (showMonthlyReportHint) { + MonthlyReportHint( + modifier = Modifier + .padding(contentPadding) + .align(Alignment.TopEnd) + .offset(x = (-37).dp, y = (-6).dp), + onDismiss = onMonthlyReportHintDismissed, + ) + } + + if (showAccountSelectorModal) { + ModalBottomSheet( + onDismissRequest = { + showAccountSelectorModal = false + }, + sheetState = accountSelectorModalSheetState, + windowInsets = WindowInsets(0, 0, 0, 0), + ) { + AccountSelectorView( + onAccountSelected = { account -> + onAccountSelected(account) + coroutineScope.launch { + accountSelectorModalSheetState.hide() + showAccountSelectorModal = false + } + }, + onOpenBecomeProScreen = { + navigateToPremium(true) + + coroutineScope.launch { + accountSelectorModalSheetState.hide() + showAccountSelectorModal = false + } + }, + onOpenLoginScreen = { shouldDismissAfterAuth -> + navigateToLogin(shouldDismissAfterAuth) + }, + onOpenCreateAccountScreen = { + navigateToCreateAccount() + }, + ) + } + } + + AnimatedVisibility( + visible = showFABMenu, + enter = fadeIn(), + exit = fadeOut(), + ) { + FABMenuOverlay( + onAddRecurringEntryPressed = { + onAddRecurringEntryPressed() + showFABMenu = false + }, + onAddEntryPressed = { + onAddEntryPressed() + showFABMenu = false + }, + onTapOutsideCTAs = { + showFABMenu = false + } + ) + } + } + } + ) +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun ProAccountSelectedPreview() { + Preview( + dbState = MainViewModel.DBState.Loaded(AppModule.provideDB(LocalContext.current)), + ) +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun DBLoadingPreview() { + Preview( + dbState = MainViewModel.DBState.Loading, + ) +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun DBErrorLoadingPreview() { + Preview( + dbState = MainViewModel.DBState.Error(RuntimeException("Error")), + ) +} + +@Composable +private fun Preview( + dbState: MainViewModel.DBState, +) { + AppTheme { + MainView( + selectedAccountFlow = MutableStateFlow(MainViewModel.SelectedAccount.Selected.Online( + name = "Account name", + isOwner = true, + ownerEmail = "test@test.com", + accountId = "accountId", + accountSecret = "accountSecret", + )), + dbStateFlow = MutableStateFlow(dbState), + eventFlow = MutableSharedFlow(), + showMonthlyReportHintFlow = MutableStateFlow(false), + openAddExpenseScreenLiveFlow = MutableSharedFlow(), + openAddRecurringExpenseScreenLiveFlow = MutableSharedFlow(), + openMonthlyReportScreenFromNotificationFlow = MutableSharedFlow(), + forceRefreshDataFlow = MutableSharedFlow(), + firstDayOfWeekFlow = MutableStateFlow(DayOfWeek.MONDAY), + includeCheckedBalanceFlow = MutableStateFlow(true), + getDataForMonth = { yearMonth -> + DataForMonth( + month = yearMonth, + daysData = yearMonth.lengthOfMonth().let { days -> + (-6..days + 6).associate { day -> + val date = yearMonth.atStartOfMonth().plusDays(day.toLong()) + + Pair( + date, + DataForDay( + day = date, + expenses = emptyList(), + balance = 0.0, + checkedBalance = 0.0, + ) + ) + } + } + ) + }, + selectedDateFlow = MutableStateFlow(LocalDate.now()), + lowMoneyAmountWarningFlow = MutableStateFlow(50), + goBackToCurrentMonthEventFlow = MutableSharedFlow(), + appInitDate = LocalDate.now(), + showActionButtonsFlow = MutableStateFlow(true), + showPremiumRelatedButtonsFlow = MutableStateFlow(true), + showManageAccountButtonFlow = MutableStateFlow(true), + showGoBackToCurrentMonthButtonFlow = MutableStateFlow(false), + hasPendingInvitationsFlow = MutableStateFlow(false), + userCurrencyFlow = MutableStateFlow(Currency.getInstance("USD")), + recurringExpenseDeletionProgressFlow = MutableStateFlow(MainViewModel.RecurringExpenseDeleteProgressState.Idle), + recurringExpenseRestoreProgressFlow = MutableStateFlow(MainViewModel.RecurringExpenseRestoreProgressState.Idle), + dayDataFlow = MutableStateFlow(MainViewModel.SelectedDateExpensesData.DataAvailable( + date = LocalDate.now(), + balance = 100.0, + checkedBalance = 20.0, + expenses = listOf( + Expense( + id = 1L, + date = LocalDate.now(), + title = "Test", + amount = 10.0, + checked = false, + ), + Expense( + id = 2L, + date = LocalDate.now(), + title = "Test 2", + amount = -10.0, + checked = true, + associatedRecurringExpense = AssociatedRecurringExpense( + recurringExpense = RecurringExpense( + title = "Test", + originalAmount = -10.0, + recurringDate = LocalDate.now(), + type = RecurringExpenseType.WEEKLY, + ), + originalDate = LocalDate.now(), + ) + ) + ), + )), + showExpensesCheckBoxFlow = MutableStateFlow(true), + onboardingResultFlow = MutableSharedFlow(), + shouldNavigateToOnboarding = false, + onSettingsButtonPressed = {}, + onAdjustCurrentBalanceButtonPressed = {}, + onTickAllPastEntriesButtonPressed = {}, + onManageAccountButtonPressed = {}, + onDiscoverPremiumButtonPressed = {}, + onMonthlyReportButtonPressed = {}, + onGoBackToCurrentMonthButtonPressed = {}, + onCurrentAccountTapped = {}, + onMonthChanged = {}, + onDateClicked = {}, + onDateLongClicked = {}, + onRetryDBLoadingButtonPressed = {}, + onAccountSelected = {}, + onExpenseDeletionCancelled = {}, + onCurrentBalanceEditedCancelled = {_, _ ->}, + onRestoreRecurringExpenseClicked = {_, _ ->}, + onCheckAllPastEntriesConfirmPressed = {}, + onNewBalanceSelected = {_, _ ->}, + onAddRecurringEntryPressed = {}, + onAddEntryPressed = {}, + onExpenseCheckedChange = {_, _ ->}, + onExpensePressed = {}, + onExpenseLongPressed = {}, + onDeleteRecurringExpenseClicked = {_, _ ->}, + onDeleteExpenseClicked = {}, + onEditExpensePressed = {}, + onEditRecurringExpenseOccurrenceAndFollowingOnesPressed = {}, + onEditRecurringExpenseOccurrencePressed = {}, + navigateToOnboarding = {}, + onOnboardingResult = {}, + closeApp = {}, + navigateToPremium = {}, + navigateToMonthlyReport = {}, + navigateToManageAccount = {}, + navigateToSettings = {}, + navigateToLogin = {}, + navigateToCreateAccount = {}, + navigateToAddExpense = { _, _ -> }, + navigateToAddRecurringExpense = { _, _ -> }, + onMonthlyReportHintDismissed = {}, + ) + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/MainViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/MainViewModel.kt index bae63d28..ae14c1fa 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/MainViewModel.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/MainViewModel.kt @@ -16,7 +16,8 @@ package com.benoitletondor.easybudgetapp.view.main -import android.os.Parcelable +import android.content.Context +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.benoitletondor.easybudgetapp.accounts.Accounts @@ -24,17 +25,43 @@ import com.benoitletondor.easybudgetapp.accounts.model.Account import com.benoitletondor.easybudgetapp.accounts.model.AccountCredentials import com.benoitletondor.easybudgetapp.auth.Auth import com.benoitletondor.easybudgetapp.auth.AuthState +import com.benoitletondor.easybudgetapp.db.DB +import com.benoitletondor.easybudgetapp.db.RestoreAction +import com.benoitletondor.easybudgetapp.db.onlineimpl.OnlineDB import com.benoitletondor.easybudgetapp.helper.Logger import com.benoitletondor.easybudgetapp.iab.Iab import com.benoitletondor.easybudgetapp.helper.MutableLiveFlow +import com.benoitletondor.easybudgetapp.helper.watchUserCurrency import com.benoitletondor.easybudgetapp.iab.PremiumCheckStatus +import com.benoitletondor.easybudgetapp.injection.AppModule +import com.benoitletondor.easybudgetapp.injection.CurrentDBProvider +import com.benoitletondor.easybudgetapp.model.DataForMonth +import com.benoitletondor.easybudgetapp.model.Expense +import com.benoitletondor.easybudgetapp.model.RecurringExpense +import com.benoitletondor.easybudgetapp.model.RecurringExpenseDeleteType +import com.benoitletondor.easybudgetapp.parameters.ONBOARDING_STEP_COMPLETED import com.benoitletondor.easybudgetapp.parameters.Parameters +import com.benoitletondor.easybudgetapp.parameters.getInitDate import com.benoitletondor.easybudgetapp.parameters.getLatestSelectedOnlineAccountId +import com.benoitletondor.easybudgetapp.parameters.getOnboardingStep import com.benoitletondor.easybudgetapp.parameters.setLatestSelectedOnlineAccountId +import com.benoitletondor.easybudgetapp.parameters.setUserSawMonthlyReportHint +import com.benoitletondor.easybudgetapp.parameters.watchFirstDayOfWeek +import com.benoitletondor.easybudgetapp.parameters.watchLowMoneyWarningAmount +import com.benoitletondor.easybudgetapp.parameters.watchShouldShowCheckedBalance +import com.benoitletondor.easybudgetapp.parameters.watchUserSawMonthlyReportHint +import com.benoitletondor.easybudgetapp.view.onboarding.OnboardingResult import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import io.realm.kotlin.mongodb.exceptions.AuthException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import kotlinx.parcelize.Parcelize +import kotlinx.coroutines.withContext +import java.time.LocalDate +import java.time.YearMonth import javax.inject.Inject @HiltViewModel @@ -43,35 +70,78 @@ class MainViewModel @Inject constructor( private val parameters: Parameters, private val accounts: Accounts, private val auth: Auth, + private val dbProvider: CurrentDBProvider, + @ApplicationContext appContext: Context, ) : ViewModel() { - val premiumStatusFlow: StateFlow = iab.iabStatusFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, PremiumCheckStatus.INITIALIZING) - - private val openPremiumEventMutableFlow = MutableLiveFlow() - val openPremiumEventFlow: Flow = openPremiumEventMutableFlow - private val selectedOnlineAccountIdMutableStateFlow: MutableStateFlow = MutableStateFlow(parameters.getLatestSelectedOnlineAccountId()) + val showMonthlyReportHintFlow: StateFlow = iab.iabStatusFlow + .flatMapLatest { + iabStatus -> when(iabStatus) { + PremiumCheckStatus.PRO_SUBSCRIBED, + PremiumCheckStatus.PREMIUM_SUBSCRIBED, + PremiumCheckStatus.LEGACY_PREMIUM -> parameters.watchUserSawMonthlyReportHint() + .map { !it } + else -> flowOf(false) + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + private val selectedDateMutableStateFlow = MutableStateFlow(LocalDate.now()) + val selectedDateFlow: StateFlow = selectedDateMutableStateFlow + private val eventMutableFlow = MutableLiveFlow() val eventFlow: Flow = eventMutableFlow - val hasPendingInvitationsFlow: StateFlow = iab.iabStatusFlow.flatMapLatest { iabStatus -> - when(iabStatus) { - PremiumCheckStatus.PRO_SUBSCRIBED -> auth.state - .flatMapLatest { authState -> - when(authState) { - is AuthState.Authenticated -> accounts.watchHasPendingInvitedAccounts(authState.currentUser) - AuthState.Authenticating, - AuthState.NotAuthenticated -> flowOf(false) - } - } - else -> flowOf(false) + private val forceRefreshMutableFlow = MutableLiveFlow() + val forceRefreshFlow: Flow = forceRefreshMutableFlow + + private val recurringExpenseDeletionProgressStateMutableFlow = MutableStateFlow( + RecurringExpenseDeleteProgressState.Idle) + val recurringExpenseDeletionProgressStateFlow: StateFlow = recurringExpenseDeletionProgressStateMutableFlow + + private val recurringExpenseRestoreProgressStateMutableFlow = MutableStateFlow( + RecurringExpenseRestoreProgressState.Idle) + val recurringExpenseRestoreProgressStateFlow: StateFlow = recurringExpenseRestoreProgressStateMutableFlow + + private val showGoToCurrentMonthButtonStateMutableFlow = MutableStateFlow(false) + val showGoToCurrentMonthButtonStateFlow: StateFlow = showGoToCurrentMonthButtonStateMutableFlow + + private val retryLoadingAccountsEventMutableFlow = MutableSharedFlow() + private val retryLoadingDBEventMutableFlow = MutableSharedFlow() + + val includeCheckedBalanceFlow = iab.iabStatusFlow + .mapNotNull { when(it) { + PremiumCheckStatus.INITIALIZING, + PremiumCheckStatus.CHECKING -> null + PremiumCheckStatus.ERROR, + PremiumCheckStatus.NOT_PREMIUM -> false + PremiumCheckStatus.LEGACY_PREMIUM, + PremiumCheckStatus.PREMIUM_SUBSCRIBED, + PremiumCheckStatus.PRO_SUBSCRIBED -> true + } } + .distinctUntilChanged() + .combine(parameters.watchShouldShowCheckedBalance()) { isPremium, shouldShowCheckedBalance -> + isPremium && shouldShowCheckedBalance } - } - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + .stateIn(viewModelScope, SharingStarted.Eagerly, false) - private val retryEventMutableFlow = MutableSharedFlow() + val hasPendingInvitationsFlow: StateFlow = iab.iabStatusFlow + .flatMapLatest { iabStatus -> + when(iabStatus) { + PremiumCheckStatus.PRO_SUBSCRIBED -> auth.state + .flatMapLatest { authState -> + when(authState) { + is AuthState.Authenticated -> accounts.watchHasPendingInvitedAccounts(authState.currentUser) + AuthState.Authenticating, + AuthState.NotAuthenticated -> flowOf(false) + } + } + else -> flowOf(false) + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) val accountSelectionFlow: StateFlow = combine( selectedOnlineAccountIdMutableStateFlow, @@ -122,31 +192,115 @@ class MainViewModel @Inject constructor( Logger.error("Error while building accountSelectionFlow", cause) emit(SelectedAccount.Selected.Offline) - retryEventMutableFlow.first() + retryLoadingAccountsEventMutableFlow.first() true }.stateIn(viewModelScope, SharingStarted.Eagerly, SelectedAccount.Loading) - fun onBecomePremiumButtonPressed() { - viewModelScope.launch { - openPremiumEventMutableFlow.emit(Unit) + private var changesWatchingJob: Job? = null + val dbAvailableFlow: StateFlow = accountSelectionFlow + .flatMapLatest { selectedAccount -> + changesWatchingJob?.cancel() + + when(selectedAccount) { + SelectedAccount.Loading -> flowOf(DBState.NotLoaded) + is SelectedAccount.Selected -> flow { + emit(DBState.Loading) + val db = withContext(Dispatchers.IO) { + when(selectedAccount) { + SelectedAccount.Selected.Offline -> AppModule.provideDB(appContext) + is SelectedAccount.Selected.Online -> { + val currentUser = (auth.state.value as? AuthState.Authenticated)?.currentUser ?: throw IllegalStateException("User is not authenticated") + + AppModule.provideSyncedOnlineDBOrThrow( + currentUser = currentUser, + accountId = selectedAccount.accountId, + accountSecret = selectedAccount.accountSecret, + ) + } + } + } + + changesWatchingJob = viewModelScope.launch { + db.onChangeFlow + .collect { + forceRefreshMutableFlow.emit(Unit) + } + } + + dbProvider.activeDB = db + + emit(DBState.Loaded(db)) + } + } + } + .retryWhen { cause, _ -> + Logger.error("Error while loading DB", cause) + emit(DBState.Error(cause)) + + retryLoadingDBEventMutableFlow.first() + emit(DBState.Loading) + + if (cause is AuthException) { + try { + Logger.debug("Refreshing user tokens") + auth.refreshUserTokens() + } catch (e: Exception) { + if (e is CancellationException) throw e + + Logger.error("Error while force refreshing user token", e) + } + } + + true } + .stateIn(viewModelScope, SharingStarted.Eagerly, DBState.NotLoaded) + + val selectedDateDataFlow = combine( + dbAvailableFlow.filterIsInstance(), + selectedDateMutableStateFlow, + includeCheckedBalanceFlow, + forceRefreshMutableFlow + .onStart { + emit(Unit) + }, + ) { _, date, includeCheckedBalance, _ -> + val (balance, expenses, checkedBalance) = withContext(Dispatchers.Default) { + Triple( + getBalanceForDay(date), + awaitDB().getExpensesForDay(date), + if (includeCheckedBalance) { + getCheckedBalanceForDay(date) + } else { + null + }, + ) + } + + SelectedDateExpensesData.DataAvailable(date, balance, checkedBalance, expenses) as SelectedDateExpensesData } + .catch { e -> + Logger.error("Error while getting selected date data", e) + emit(SelectedDateExpensesData.NoDataAvailable) + } + .stateIn(viewModelScope, SharingStarted.Eagerly, SelectedDateExpensesData.NoDataAvailable) - fun onWelcomeScreenFinished() { - // No-op + fun onDiscoverPremiumButtonPressed() { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenPremium) + } } - fun onAccountTapped() { + fun onCurrentAccountTapped() { viewModelScope.launch { - retryEventMutableFlow.emit(Unit) + retryLoadingAccountsEventMutableFlow.emit(Unit) eventMutableFlow.emit(Event.ShowAccountSelect) } } fun onAccountSelected(account: SelectedAccount.Selected) { viewModelScope.launch { - retryEventMutableFlow.emit(Unit) + retryLoadingAccountsEventMutableFlow.emit(Unit) } val onlineAccountId = when(account) { @@ -158,16 +312,431 @@ class MainViewModel @Inject constructor( selectedOnlineAccountIdMutableStateFlow.value = onlineAccountId } - fun shouldShowMenuButtons(): Boolean = iab.isIabReady() && accountSelectionFlow.value is SelectedAccount.Selected + val showMenuActionButtonsFlow: StateFlow = iab.iabStatusFlow + .map { iab.isIabReady() } + .distinctUntilChanged() + .flatMapLatest { isIabReady -> + if (!isIabReady) { + flowOf(false) + } else { + accountSelectionFlow.map { it is SelectedAccount.Selected } + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val showPremiumRelatedButtonsFlow: StateFlow = iab.iabStatusFlow + .map { status -> + when(status) { + PremiumCheckStatus.INITIALIZING, + PremiumCheckStatus.CHECKING, + PremiumCheckStatus.ERROR, + PremiumCheckStatus.NOT_PREMIUM -> false + PremiumCheckStatus.LEGACY_PREMIUM, + PremiumCheckStatus.PREMIUM_SUBSCRIBED, + PremiumCheckStatus.PRO_SUBSCRIBED -> true + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val showExpensesCheckBoxFlow: StateFlow = showPremiumRelatedButtonsFlow + + val showManageAccountMenuItemFlow: StateFlow = combine(dbAvailableFlow, accountSelectionFlow) { dbState, accountSelection -> + when(dbState) { + DBState.NotLoaded, + DBState.Loading, + is DBState.Error -> false + is DBState.Loaded -> when(accountSelection) { + SelectedAccount.Loading -> false + is SelectedAccount.Selected -> accountSelection is SelectedAccount.Selected.Online + } + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val firstDayOfWeekFlow = parameters.watchFirstDayOfWeek() + + val lowMoneyAmountWarningFlow = parameters.watchLowMoneyWarningAmount() + + val userCurrencyFlow = parameters.watchUserCurrency() + + val appInitDate: LocalDate get() = parameters.getInitDate() ?: LocalDate.now() + + val shouldNavigateToOnboarding get() = parameters.getOnboardingStep() != ONBOARDING_STEP_COMPLETED + + fun onOnboardingResult(onboardingResult: OnboardingResult) { + viewModelScope.launch { + if (onboardingResult.onboardingCompleted) { + // We do this because the onboarding is using an injected DB directly that isn't the one + // we currently use so we're not getting update events + (dbAvailableFlow.value as? DBState.Loaded)?.db?.forceCacheWipe() + forceRefreshMutableFlow.emit(Unit) + } else { + eventMutableFlow.emit(Event.CloseApp) + } + } + + } + + suspend fun getDataForMonth(month: YearMonth): DataForMonth = awaitDB().getDataForMonth(month) + + fun onRetryLoadingDBButtonPressed() { + viewModelScope.launch { + retryLoadingDBEventMutableFlow.emit(Unit) + } + } + + fun onMonthlyReportButtonPressed() { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenMonthlyReport) + } + } + + fun onEditExpensePressed(expense: Expense) { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenEditExpense(expense)) + } + } + + fun onEditRecurringExpenseOccurenceAndFollowingOnesPressed(expense: Expense) { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenEditRecurringExpenseOccurrenceAndFollowingOnes(expense)) + } + } + + fun onEditRecurringExpenseOccurencePressed(expense: Expense) { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenEditRecurringExpenseOccurrence(expense)) + } + } + + fun onMonthlyReportHintDismissed() { + parameters.setUserSawMonthlyReportHint() + } + + fun onDeleteExpenseClicked(expense: Expense) { + viewModelScope.launch { + try { + val restoreAction = withContext(Dispatchers.IO) { + awaitDB().deleteExpense(expense) + } + + eventMutableFlow.emit( + Event.ExpenseDeletionSuccess( + ExpenseDeletionSuccessData( + expense, + restoreAction, + ) + ) + ) + } catch (t: Throwable) { + Logger.error("Error while deleting expense", t) + eventMutableFlow.emit(Event.ExpenseDeletionError(expense)) + } + } + } + + fun onExpenseDeletionCancelled(restoreAction: RestoreAction) { + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + restoreAction() + } + } catch (t: Throwable) { + Logger.error("Error while restoring expense", t) + } + } + } + + fun onDeleteRecurringExpenseClicked(expense: Expense, deleteType: RecurringExpenseDeleteType) { + viewModelScope.launch { + recurringExpenseDeletionProgressStateMutableFlow.value = RecurringExpenseDeleteProgressState.Deleting(expense) + + try { + val associatedRecurringExpense = expense.associatedRecurringExpense + if( associatedRecurringExpense == null ) { + eventMutableFlow.emit(Event.RecurringExpenseDeletionResult(RecurringExpenseDeletionEvent.ErrorRecurringExpenseDeleteNotAssociated(expense))) + return@launch + } + + val firstOccurrenceError = withContext(Dispatchers.Default) { + deleteType == RecurringExpenseDeleteType.TO && !awaitDB().hasExpensesForRecurringExpenseBeforeDate(associatedRecurringExpense.recurringExpense, expense.date) + } + + if ( firstOccurrenceError ) { + eventMutableFlow.emit(Event.RecurringExpenseDeletionResult(RecurringExpenseDeletionEvent.ErrorCantDeleteBeforeFirstOccurrence(expense))) + return@launch + } + + val restoreAction: RestoreAction? = withContext(Dispatchers.IO) { + when (deleteType) { + RecurringExpenseDeleteType.ALL -> { + try { + awaitDB().deleteRecurringExpense(associatedRecurringExpense.recurringExpense) + } catch (e: Exception) { + if (e is CancellationException) throw e + + Logger.error("Error while deleting recurring expense", e) + null + } + } + RecurringExpenseDeleteType.FROM -> { + try { + awaitDB().deleteAllExpenseForRecurringExpenseAfterDate(associatedRecurringExpense.recurringExpense, expense.date) + } catch (e: Exception) { + if (e is CancellationException) throw e + + Logger.error("Error while deleting recurring expense from", e) + null + } + } + RecurringExpenseDeleteType.TO -> { + try { + awaitDB().deleteAllExpenseForRecurringExpenseBeforeDate(associatedRecurringExpense.recurringExpense, expense.date) + } catch (e: Exception) { + if (e is CancellationException) throw e + + Logger.error("Error while deleting recurring expense to", e) + null + } + } + RecurringExpenseDeleteType.ONE -> { + try { + awaitDB().deleteExpense(expense) + } catch (e: Exception) { + if (e is CancellationException) throw e + + Logger.error("Error while deleting recurring expense one", e) + null + } + } + } + } + + if( restoreAction == null ) { + eventMutableFlow.emit(Event.RecurringExpenseDeletionResult(RecurringExpenseDeletionEvent.ErrorIO(expense))) + return@launch + } + + eventMutableFlow.emit(Event.RecurringExpenseDeletionResult(RecurringExpenseDeletionEvent.Success(associatedRecurringExpense.recurringExpense, restoreAction))) + } finally { + recurringExpenseDeletionProgressStateMutableFlow.value = RecurringExpenseDeleteProgressState.Idle + } + } + + } + + fun onRestoreRecurringExpenseClicked(recurringExpense: RecurringExpense, restoreAction: RestoreAction) { + viewModelScope.launch { + recurringExpenseRestoreProgressStateMutableFlow.value = RecurringExpenseRestoreProgressState.Restoring(recurringExpense) + + try { + restoreAction() + eventMutableFlow.emit(Event.RecurringExpenseRestoreResult(RecurringExpenseRestoreEvent.Success(recurringExpense))) + } catch (e: Exception) { + if (e is CancellationException) throw e + + eventMutableFlow.emit(Event.RecurringExpenseRestoreResult(RecurringExpenseRestoreEvent.ErrorIO(recurringExpense))) + } finally { + recurringExpenseRestoreProgressStateMutableFlow.value = RecurringExpenseRestoreProgressState.Idle + } + } + } + + fun onAdjustCurrentBalanceClicked() { + viewModelScope.launch { + val balance = withContext(Dispatchers.Default) { + -awaitDB().getBalanceForDay(LocalDate.now()) + } + + eventMutableFlow.emit(Event.StartCurrentBalanceEditor(balance)) + } + } + + fun onNewBalanceSelected(newBalance: Double, balanceExpenseTitle: String) { + viewModelScope.launch { + try { + val currentBalance = withContext(Dispatchers.Default) { + -awaitDB().getBalanceForDay(LocalDate.now()) + } + + if (newBalance == currentBalance) { + // Nothing to do, balance hasn't change + return@launch + } + + val diff = newBalance - currentBalance + + // Look for an existing balance for the day + val existingExpense = withContext(Dispatchers.Default) { + awaitDB().getExpensesForDay(LocalDate.now()).find { it.title == balanceExpenseTitle } + } + + if (existingExpense != null) { // If the adjust balance exists, just add the diff and persist it + val newExpense = withContext(Dispatchers.Default) { + awaitDB().persistExpense(existingExpense.copy(amount = existingExpense.amount - diff)) + } + + eventMutableFlow.emit(Event.CurrentBalanceEditionSuccess(BalanceAdjustedData(newExpense, diff, newBalance))) + } else { // If no adjust balance yet, create a new one + val persistedExpense = withContext(Dispatchers.Default) { + awaitDB().persistExpense(Expense(balanceExpenseTitle, -diff, LocalDate.now(), true)) + } + + eventMutableFlow.emit(Event.CurrentBalanceEditionSuccess(BalanceAdjustedData(persistedExpense, diff, newBalance))) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + + Logger.error("Error while editing balance", e) + eventMutableFlow.emit(Event.CurrentBalanceEditionError(e)) + } + } + } + + fun onCurrentBalanceEditedCancelled(expense: Expense, diff: Double) { + viewModelScope.launch { + try { + withContext(Dispatchers.Default) { + if( expense.amount + diff == 0.0 ) { + awaitDB().deleteExpense(expense) + } else { + val newExpense = expense.copy(amount = expense.amount + diff) + awaitDB().persistExpense(newExpense) + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + + Logger.error("Error while restoring balance", e) + eventMutableFlow.emit(Event.CurrentBalanceRestorationError(e)) + } + } + } + + fun onSelectDate(date: LocalDate) { + selectedDateMutableStateFlow.value = date + } + + fun onDateLongClicked(date: LocalDate) { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenAddExpense(date)) + } + } + + fun onExpenseChecked(expense: Expense, checked: Boolean) { + viewModelScope.launch { + try { + withContext(Dispatchers.Default) { + awaitDB().persistExpense(expense.copy(checked = checked)) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + + Logger.error("Error while checking expense", e) + eventMutableFlow.emit(Event.ExpenseCheckingError(e)) + } + } + } + + fun onMonthChanged(yearMonth: YearMonth) { + showGoToCurrentMonthButtonStateMutableFlow.value = yearMonth != YearMonth.now() + } - fun showPremiumMenuButtons(): Boolean = when(iab.iabStatusFlow.value) { - PremiumCheckStatus.INITIALIZING, - PremiumCheckStatus.CHECKING, - PremiumCheckStatus.ERROR, - PremiumCheckStatus.NOT_PREMIUM -> false - PremiumCheckStatus.LEGACY_PREMIUM, - PremiumCheckStatus.PREMIUM_SUBSCRIBED, - PremiumCheckStatus.PRO_SUBSCRIBED -> true + fun onGoBackToCurrentMonthButtonPressed() { + viewModelScope.launch { + selectedDateMutableStateFlow.value = LocalDate.now() + eventMutableFlow.emit(Event.GoBackToCurrentMonth) + } + } + + fun onCheckAllPastEntriesPressed() { + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowConfirmCheckAllPastEntries) + } + } + + fun onSettingsButtonPressed() { + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowSettings) + } + } + + fun onCheckAllPastEntriesConfirmPressed() { + viewModelScope.launch { + try { + withContext(Dispatchers.Default) { + awaitDB().markAllEntriesAsChecked(LocalDate.now()) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + + Logger.error("Error while checking all past entries", e) + eventMutableFlow.emit(Event.CheckAllPastEntriesError(e)) + } + } + } + + fun onManageAccountButtonPressed() { + viewModelScope.launch { + (accountSelectionFlow.value as? SelectedAccount.Selected.Online)?.let { + eventMutableFlow.emit(Event.OpenManageAccount(it)) + } + } + } + + fun onAddRecurringEntryPressed() { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenAddRecurringExpense(selectedDateFlow.value)) + } + } + + fun onAddEntryPressed() { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenAddExpense(selectedDateFlow.value)) + } + } + + fun onExpensePressed(expense: Expense) { + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowExpenseEditionOptions(expense)) + } + } + + fun onExpenseLongPressed(expense: Expense) { + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowExpenseEditionOptions(expense)) + } + } + + override fun onCleared() { + val currentDB = dbProvider.activeDB + val dbState = dbAvailableFlow.value as? DBState.Loaded + if (currentDB != null && dbState != null && dbState.db == currentDB) { + dbProvider.activeDB = null + } + + try { + (dbState?.db as? OnlineDB)?.close() + } catch (e: Exception) { + Logger.warning("Error while trying to close online DB when clearing, continuing") + } + + changesWatchingJob?.cancel() + + super.onCleared() + } + + private suspend fun getBalanceForDay(date: LocalDate): Double { + var balance = 0.0 // Just to keep a positive number if balance == 0 + balance -= awaitDB().getBalanceForDay(date) + + return balance + } + + private suspend fun getCheckedBalanceForDay(date: LocalDate): Double { + var balance = 0.0 // Just to keep a positive number if balance == 0 + balance -= awaitDB().getCheckedBalanceForDay(date) + + return balance } private sealed class OnlineAccountResponse { @@ -177,10 +746,9 @@ class MainViewModel @Inject constructor( sealed class SelectedAccount { data object Loading : SelectedAccount() - sealed class Selected : SelectedAccount(), Parcelable { - @Parcelize - object Offline : Selected() - @Parcelize + sealed class Selected : SelectedAccount() { + data object Offline : Selected() + @Immutable data class Online( val name: String, val isOwner: Boolean, @@ -196,7 +764,73 @@ class MainViewModel @Inject constructor( } } + sealed class RecurringExpenseDeleteProgressState { + data object Idle : RecurringExpenseDeleteProgressState() + class Deleting(val expense: Expense): RecurringExpenseDeleteProgressState() + } + + sealed class RecurringExpenseDeletionEvent { + class ErrorRecurringExpenseDeleteNotAssociated(val expense: Expense): RecurringExpenseDeletionEvent() + class ErrorCantDeleteBeforeFirstOccurrence(val expense: Expense): RecurringExpenseDeletionEvent() + class ErrorIO(val expense: Expense): RecurringExpenseDeletionEvent() + class Success(val recurringExpense: RecurringExpense, val restoreAction: RestoreAction): RecurringExpenseDeletionEvent() + } + + sealed class RecurringExpenseRestoreProgressState { + data object Idle : RecurringExpenseRestoreProgressState() + class Restoring(val recurringExpense: RecurringExpense): RecurringExpenseRestoreProgressState() + } + + sealed class RecurringExpenseRestoreEvent { + class ErrorIO(val recurringExpense: RecurringExpense): RecurringExpenseRestoreEvent() + class Success(val recurringExpense: RecurringExpense): RecurringExpenseRestoreEvent() + } + sealed class Event { data object ShowAccountSelect : Event() + data object ShowSettings : Event() + data object OpenPremium : Event() + data object GoBackToCurrentMonth : Event() + data class ExpenseDeletionSuccess(val data: ExpenseDeletionSuccessData) : Event() + data class ExpenseDeletionError(val expense: Expense) : Event() + data class RecurringExpenseDeletionResult(val data: RecurringExpenseDeletionEvent) : Event() + data class RecurringExpenseRestoreResult(val data: RecurringExpenseRestoreEvent) : Event() + data class StartCurrentBalanceEditor(val currentBalance: Double) : Event() + data class CurrentBalanceEditionError(val error: Exception) : Event() + data class CurrentBalanceEditionSuccess(val data: BalanceAdjustedData) : Event() + data class CurrentBalanceRestorationError(val error: Exception) : Event() + data class ExpenseCheckingError(val error: Exception) : Event() + data object ShowConfirmCheckAllPastEntries : Event() + data class CheckAllPastEntriesError(val error: Throwable) : Event() + data object OpenMonthlyReport : Event() + data class OpenAddRecurringExpense(val date: LocalDate) : Event() + data class OpenAddExpense(val date: LocalDate) : Event() + data class OpenManageAccount(val account: SelectedAccount.Selected.Online) : Event() + data class ShowExpenseEditionOptions(val expense: Expense) : Event() + data class OpenEditExpense(val expense: Expense) : Event() + data class OpenEditRecurringExpenseOccurrenceAndFollowingOnes(val expense: Expense) : Event() + data class OpenEditRecurringExpenseOccurrence(val expense: Expense) : Event() + data object StartOnboarding : Event() + data object CloseApp : Event() + } + + + sealed class SelectedDateExpensesData { + data object NoDataAvailable : SelectedDateExpensesData() + @Immutable + data class DataAvailable(val date: LocalDate, val balance: Double, val checkedBalance: Double?, val expenses: List) : SelectedDateExpensesData() + } + + data class ExpenseDeletionSuccessData(val deletedExpense: Expense, val restoreAction: RestoreAction) + data class BalanceAdjustedData(val balanceExpense: Expense, val diffWithOldBalance: Double, val newBalance: Double) + + private suspend fun awaitDB() = dbAvailableFlow.filterIsInstance().first().db + + sealed class DBState { + data object NotLoaded : DBState() + data object Loading : DBState() + @Immutable + class Loaded(val db: DB) : DBState() + class Error(val error: Throwable) : DBState() } } \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/AccountFragment.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/AccountFragment.kt deleted file mode 100644 index f99c83cd..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/AccountFragment.kt +++ /dev/null @@ -1,741 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.main.account - -import android.app.Dialog -import android.app.ProgressDialog -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.res.Configuration -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import android.view.animation.AlphaAnimation -import android.view.animation.Animation -import android.widget.EditText -import androidx.core.app.ActivityCompat -import androidx.core.app.ActivityCompat.invalidateOptionsMenu -import androidx.core.content.ContextCompat -import androidx.core.view.MenuProvider -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.recyclerview.widget.LinearLayoutManager -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.databinding.FragmentAccountBinding -import com.benoitletondor.easybudgetapp.helper.CurrencyHelper -import com.benoitletondor.easybudgetapp.helper.Logger -import com.benoitletondor.easybudgetapp.helper.launchCollect -import com.benoitletondor.easybudgetapp.helper.preventUnsupportedInputForDecimals -import com.benoitletondor.easybudgetapp.helper.viewLifecycleScope -import com.benoitletondor.easybudgetapp.iab.INTENT_IAB_STATUS_CHANGED -import com.benoitletondor.easybudgetapp.iab.Iab -import com.benoitletondor.easybudgetapp.iab.PremiumCheckStatus -import com.benoitletondor.easybudgetapp.model.Expense -import com.benoitletondor.easybudgetapp.model.RecurringExpenseDeleteType -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.benoitletondor.easybudgetapp.parameters.getLowMoneyWarningAmount -import com.benoitletondor.easybudgetapp.theme.AppTheme -import com.benoitletondor.easybudgetapp.view.expenseedit.ExpenseEditActivity -import com.benoitletondor.easybudgetapp.view.main.MainActivity -import com.benoitletondor.easybudgetapp.view.main.MainViewModel -import com.benoitletondor.easybudgetapp.view.main.account.calendar.CalendarView -import com.benoitletondor.easybudgetapp.view.main.manageaccount.ManageAccountActivity -import com.benoitletondor.easybudgetapp.view.recurringexpenseadd.RecurringExpenseEditActivity -import com.benoitletondor.easybudgetapp.view.report.base.MonthlyReportBaseActivity -import com.benoitletondor.easybudgetapp.view.selectcurrency.SelectCurrencyFragment -import com.benoitletondor.easybudgetapp.view.welcome.WelcomeActivity -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.BaseTransientBottomBar -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.util.Locale -import javax.inject.Inject - -@AndroidEntryPoint -class AccountFragment : Fragment(), MenuProvider { - private val viewModel: AccountViewModel by viewModels() - - private val receiver: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - MainActivity.INTENT_EXPENSE_DELETED -> { - val expense = intent.getParcelableExtra("expense")!! - - viewModel.onDeleteExpenseClicked(expense) - } - MainActivity.INTENT_RECURRING_EXPENSE_DELETED -> { - val expense = intent.getParcelableExtra("expense")!! - val deleteType = RecurringExpenseDeleteType.fromValue(intent.getIntExtra("deleteType", RecurringExpenseDeleteType.ALL.value))!! - - viewModel.onDeleteRecurringExpenseClicked(expense, deleteType) - } - SelectCurrencyFragment.CURRENCY_SELECTED_INTENT -> viewModel.onCurrencySelected() - MainActivity.INTENT_SHOW_WELCOME_SCREEN -> { - val startIntent = Intent(requireContext(), WelcomeActivity::class.java) - ActivityCompat.startActivityForResult(requireActivity(), startIntent, - MainActivity.WELCOME_SCREEN_ACTIVITY_CODE, null) - } - INTENT_IAB_STATUS_CHANGED -> viewModel.onIabStatusChanged() - MainActivity.INTENT_SHOW_CHECKED_BALANCE_CHANGED -> viewModel.onShowCheckedBalanceChanged() - MainActivity.INTENT_LOW_MONEY_WARNING_THRESHOLD_CHANGED -> viewModel.onLowMoneyWarningThresholdChanged() - } - } - } - - private lateinit var balanceDateFormatter: DateTimeFormatter - private lateinit var expensesViewAdapter: ExpensesRecyclerViewAdapter - - private val menuBackgroundAnimationDuration: Long = 150 - private var menuExpandAnimation: Animation? = null - private var menuCollapseAnimation: Animation? = null - - private var isMenuExpended = false - - @Inject - lateinit var parameters: Parameters - @Inject - lateinit var iab: Iab - - private var _binding: FragmentAccountBinding? = null - private val binding: FragmentAccountBinding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentAccountBinding.inflate(inflater, container, false) - _binding = binding - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - balanceDateFormatter = DateTimeFormatter.ofPattern(resources.getString(R.string.account_balance_date_format), Locale.getDefault()) - - initCalendarView() - initFab() - initRecyclerView() - registerBroadcastReceiver() - observeViewModel() - - requireActivity().addMenuProvider(this, viewLifecycleOwner) - } - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.menu_account, menu) - - if (!viewModel.shouldShowPremiumRelatedButtons) { - // Remove monthly report for non premium users - menu.removeItem(R.id.action_monthly_report) - menu.removeItem(R.id.action_check_all_past_entries) - } - - // Remove back to today button if needed - if (!viewModel.showGoToCurrentMonthButtonStateFlow.value) { - menu.removeItem(R.id.action_go_to_current_month) - } - - // Remove manage account if needed - if (!viewModel.showManageAccountMenuItem.value) { - menu.removeItem(R.id.action_manage_account) - } - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when(menuItem.itemId) { - R.id.action_go_to_current_month -> { - viewModel.onGoBackToCurrentMonthButtonPressed() - true - } - R.id.action_balance -> { - viewModel.onAdjustCurrentBalanceClicked() - true - } - R.id.action_check_all_past_entries -> { - viewModel.onCheckAllPastEntriesPressed() - true - } - R.id.action_go_to_current_month -> { - viewModel.onGoBackToCurrentMonthButtonPressed() - true - } - R.id.action_monthly_report -> { - viewModel.onMonthlyReportButtonPressed() - true - } - R.id.action_manage_account -> { - viewModel.onManageAccountButtonPressed() - true - } - else -> false - } - } - - override fun onDestroyView() { - LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(receiver) - _binding = null - - super.onDestroyView() - } - - private fun observeViewModel() { - viewLifecycleScope.launchCollect(viewModel.dbAvailableFlow) { dbState -> - binding.accountLoadedView.isVisible = dbState is AccountViewModel.DBState.Loaded - binding.accountLoadingView.isVisible = dbState is AccountViewModel.DBState.Loading - binding.accountErrorView.isVisible = dbState is AccountViewModel.DBState.Error - - if (dbState is AccountViewModel.DBState.Error) { - binding.accountErrorMessageTextView.text = getString(R.string.account_error_loading_message, dbState.error.localizedMessage) - binding.accountErrorMessageRetryCta.setOnClickListener { - viewModel.onRetryLoadingButtonPressed() - } - } - } - - viewLifecycleScope.launchCollect(viewModel.expenseDeletionSuccessEventFlow) { (deletedExpense, restoreAction) -> - val snackbar = Snackbar.make( - binding.coordinatorLayout, - if (deletedExpense.isRevenue()) R.string.income_delete_snackbar_text else R.string.expense_delete_snackbar_text, - Snackbar.LENGTH_LONG - ) - snackbar.setAction(R.string.undo) { - viewModel.onExpenseDeletionCancelled(restoreAction) - } - snackbar.setActionTextColor( - ContextCompat.getColor( - requireContext(), - R.color.snackbar_action_undo - ) - ) - - snackbar.duration = BaseTransientBottomBar.LENGTH_LONG - snackbar.show() - } - - viewLifecycleScope.launchCollect(viewModel.expenseDeletionErrorEventFlow) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.expense_delete_error_title) - .setMessage(R.string.expense_delete_error_message) - .setNegativeButton(R.string.ok) { dialog, _ -> dialog.dismiss() } - .show() - } - - var expenseDeletionDialog: ProgressDialog? = null - viewLifecycleScope.launchCollect(viewModel.recurringExpenseDeletionProgressStateFlow) { state -> - when(state) { - is AccountViewModel.RecurringExpenseDeleteProgressState.Deleting -> { - val dialog = ProgressDialog(requireContext()) - dialog.isIndeterminate = true - dialog.setTitle(R.string.recurring_expense_delete_loading_title) - dialog.setMessage(resources.getString(R.string.recurring_expense_delete_loading_message)) - dialog.setCanceledOnTouchOutside(false) - dialog.setCancelable(false) - dialog.show() - - expenseDeletionDialog = dialog - } - AccountViewModel.RecurringExpenseDeleteProgressState.Idle -> { - expenseDeletionDialog?.dismiss() - expenseDeletionDialog = null - } - } - } - - viewLifecycleScope.launchCollect(viewModel.recurringExpenseDeletionEventFlow) { event -> - when(event) { - is AccountViewModel.RecurringExpenseDeletionEvent.ErrorCantDeleteBeforeFirstOccurrence -> { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.recurring_expense_delete_first_error_title) - .setMessage(R.string.recurring_expense_delete_first_error_message) - .setNegativeButton(R.string.ok, null) - .show() - } - is AccountViewModel.RecurringExpenseDeletionEvent.ErrorIO -> { - showGenericRecurringDeleteErrorDialog() - } - is AccountViewModel.RecurringExpenseDeletionEvent.ErrorRecurringExpenseDeleteNotAssociated -> { - showGenericRecurringDeleteErrorDialog() - } - is AccountViewModel.RecurringExpenseDeletionEvent.Success -> { - val snackbar = Snackbar.make( - binding.coordinatorLayout, - R.string.recurring_expense_delete_success_message, - Snackbar.LENGTH_LONG - ) - - snackbar.setAction(R.string.undo) { - viewModel.onRestoreRecurringExpenseClicked( - event.recurringExpense, - event.restoreAction, - ) - } - - snackbar.setActionTextColor( - ContextCompat.getColor( - requireContext(), - R.color.snackbar_action_undo - ) - ) - snackbar.duration = BaseTransientBottomBar.LENGTH_LONG - snackbar.show() - } - } - } - - var expenseRestoreDialog: Dialog? = null - viewLifecycleScope.launchCollect(viewModel.recurringExpenseRestoreProgressStateFlow) { state -> - when(state) { - AccountViewModel.RecurringExpenseRestoreProgressState.Idle -> { - expenseRestoreDialog?.dismiss() - expenseRestoreDialog = null - } - is AccountViewModel.RecurringExpenseRestoreProgressState.Restoring -> { - val dialog = ProgressDialog(requireContext()) - dialog.isIndeterminate = true - dialog.setTitle(R.string.recurring_expense_restoring_loading_title) - dialog.setMessage(resources.getString(R.string.recurring_expense_restoring_loading_message)) - dialog.setCanceledOnTouchOutside(false) - dialog.setCancelable(false) - dialog.show() - - expenseRestoreDialog = dialog - } - } - } - - viewLifecycleScope.launchCollect(viewModel.recurringExpenseRestoreEventFlow) { event -> - when(event) { - is AccountViewModel.RecurringExpenseRestoreEvent.ErrorIO -> { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.recurring_expense_restore_error_title) - .setMessage(resources.getString(R.string.recurring_expense_restore_error_message)) - .setNegativeButton(R.string.ok) { dialog, _ -> dialog.dismiss() } - .show() - } - is AccountViewModel.RecurringExpenseRestoreEvent.Success -> { - Snackbar.make( - binding.coordinatorLayout, - R.string.recurring_expense_restored_success_message, - Snackbar.LENGTH_LONG - ).show() - } - } - } - - viewLifecycleScope.launchCollect(viewModel.startCurrentBalanceEditorEventFlow) { currentBalance -> - val dialogView = layoutInflater.inflate(R.layout.dialog_adjust_balance, null) - val amountEditText = dialogView.findViewById(R.id.balance_amount) - amountEditText.setText( - if (currentBalance == 0.0) "0" else CurrencyHelper.getFormattedAmountValue( - currentBalance - ) - ) - amountEditText.preventUnsupportedInputForDecimals() - amountEditText.setSelection(amountEditText.text.length) // Put focus at the end of the text - - val builder = MaterialAlertDialogBuilder(requireContext()) - builder.setTitle(R.string.adjust_balance_title) - builder.setMessage(R.string.adjust_balance_message) - builder.setView(dialogView) - builder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } - builder.setPositiveButton(R.string.ok) { dialog, _ -> - try { - val stringValue = amountEditText.text.toString() - if (stringValue.isNotBlank()) { - val newBalance = java.lang.Double.valueOf(stringValue) - viewModel.onNewBalanceSelected( - newBalance, - getString(R.string.adjust_balance_expense_title) - ) - } - } catch (e: Exception) { - Logger.error("Error parsing new balance", e) - } - - dialog.dismiss() - } - - val dialog = builder.show() - - // Directly show keyboard when the dialog pops - amountEditText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> - // Check if the device doesn't have a physical keyboard - if (hasFocus && resources.configuration.keyboard == Configuration.KEYBOARD_NOKEYS) { - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - } - } - } - - viewLifecycleScope.launchCollect(viewModel.currentBalanceEditingErrorEventFlow) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.adjust_balance_error_title) - .setMessage(R.string.adjust_balance_error_message) - .setNegativeButton(R.string.ok) { dialog1, _ -> dialog1.dismiss() } - .show() - } - - viewLifecycleScope.launchCollect(viewModel.currentBalanceEditedEventFlow) { (expense, diff, newBalance) -> - //Show snackbar - val snackbar = Snackbar.make( - binding.coordinatorLayout, - resources.getString( - R.string.adjust_balance_snackbar_text, - CurrencyHelper.getFormattedCurrencyString(parameters, newBalance) - ), - Snackbar.LENGTH_LONG - ) - snackbar.setAction(R.string.undo) { - viewModel.onCurrentBalanceEditedCancelled(expense, diff) - } - snackbar.setActionTextColor( - ContextCompat.getColor( - requireContext(), - R.color.snackbar_action_undo - ) - ) - - snackbar.duration = BaseTransientBottomBar.LENGTH_LONG - snackbar.show() - } - - viewLifecycleScope.launchCollect(viewModel.currentBalanceRestoringErrorEventFlow) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.adjust_balance_error_title) - .setMessage(R.string.adjust_balance_error_message) - .setNegativeButton(R.string.ok) { dialog1, _ -> dialog1.dismiss() } - .show() - } - - viewLifecycleScope.launchCollect(viewModel.selectedDateDataFlow) { (date, balance, maybeCheckedBalance, expenses) -> - refreshBalanceAndExpenseListForDate(date, balance, maybeCheckedBalance, expenses) - } - - viewLifecycleScope.launchCollect(viewModel.premiumStatusFlow) { - invalidateOptionsMenu(requireActivity()) - - expensesViewAdapter.setUserPremium(isPremium = when(it) { - PremiumCheckStatus.INITIALIZING, - PremiumCheckStatus.CHECKING, - PremiumCheckStatus.ERROR, - PremiumCheckStatus.NOT_PREMIUM -> false - PremiumCheckStatus.LEGACY_PREMIUM, - PremiumCheckStatus.PREMIUM_SUBSCRIBED, - PremiumCheckStatus.PRO_SUBSCRIBED -> true - }) - } - - viewLifecycleScope.launchCollect(viewModel.expenseCheckedErrorEventFlow) { exception -> - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.expense_check_error_title) - .setMessage( - getString( - R.string.expense_check_error_message, - exception.localizedMessage - ) - ) - .setNegativeButton(R.string.ok) { dialog2, _ -> dialog2.dismiss() } - .show() - } - - viewLifecycleScope.launchCollect(viewModel.showGoToCurrentMonthButtonStateFlow) { - invalidateOptionsMenu(requireActivity()) - } - - viewLifecycleScope.launchCollect(viewModel.showManageAccountMenuItem) { - invalidateOptionsMenu(requireActivity()) - } - - viewLifecycleScope.launchCollect(viewModel.confirmCheckAllPastEntriesEventFlow) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.check_all_past_expences_title) - .setMessage(getString(R.string.check_all_past_expences_message)) - .setPositiveButton(R.string.check_all_past_expences_confirm_cta) { dialog2, _ -> - viewModel.onCheckAllPastEntriesConfirmPressed() - dialog2.dismiss() - } - .setNegativeButton(android.R.string.cancel) { dialog2, _ -> dialog2.dismiss() } - .show() - } - - viewLifecycleScope.launchCollect(viewModel.checkAllPastEntriesErrorEventFlow) { error -> - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.check_all_past_expences_error_title) - .setMessage( - getString( - R.string.check_all_past_expences_error_message, - error.localizedMessage - ) - ) - .setNegativeButton(R.string.ok) { dialog2, _ -> dialog2.dismiss() } - .show() - } - - viewLifecycleScope.launchCollect(viewModel.openMonthlyReportEventFlow) { - val startIntent = Intent(requireActivity(), MonthlyReportBaseActivity::class.java) - ActivityCompat.startActivity(requireContext(), startIntent, null) - } - - viewLifecycleScope.launchCollect(viewModel.openManageAccountEventFlow) { account -> - startActivity(ManageAccountActivity.newIntent(requireContext(), account)) - } - - viewLifecycleScope.launchCollect(viewModel.openExpenseAddEventFlow) { date -> - val startIntent = ExpenseEditActivity.newIntent( - context = requireContext(), - editedExpense = null, - date = date, - ) - - requireActivity().startActivity(startIntent) - } - } - - private fun registerBroadcastReceiver() { - // Register receiver - val filter = IntentFilter() - filter.addAction(MainActivity.INTENT_EXPENSE_DELETED) - filter.addAction(MainActivity.INTENT_RECURRING_EXPENSE_DELETED) - filter.addAction(SelectCurrencyFragment.CURRENCY_SELECTED_INTENT) - filter.addAction(MainActivity.INTENT_SHOW_WELCOME_SCREEN) - filter.addAction(Intent.ACTION_VIEW) - filter.addAction(INTENT_IAB_STATUS_CHANGED) - filter.addAction(MainActivity.INTENT_SHOW_CHECKED_BALANCE_CHANGED) - filter.addAction(MainActivity.INTENT_LOW_MONEY_WARNING_THRESHOLD_CHANGED) - - LocalBroadcastManager.getInstance(requireContext()).registerReceiver(receiver, filter) - } - -// ------------------------------------------> - - /** - * Update the balance for the given day - * TODO optimization - */ - private fun updateBalanceDisplayForDay(day: LocalDate, balance: Double, maybeCheckedBalance: Double?) { - var formatted = resources.getString(R.string.account_balance_format, balanceDateFormatter.format(day)) - - // FIXME it's ugly!! - if (formatted.endsWith(".:")) { - formatted = formatted.substring(0, formatted.length - 2) + ":" // Remove . at the end of the month (ex: nov.: -> nov:) - } else if (formatted.endsWith(". :")) { - formatted = formatted.substring(0, formatted.length - 3) + " :" // Remove . at the end of the month (ex: nov. : -> nov :) - } - - binding.budgetLine.text = formatted - binding.budgetLineAmount.text = if (maybeCheckedBalance != null) { - resources.getString( - R.string.account_balance_checked_format, - CurrencyHelper.getFormattedCurrencyString(parameters, balance), - CurrencyHelper.getFormattedCurrencyString(parameters, maybeCheckedBalance), - ) - } else { - CurrencyHelper.getFormattedCurrencyString(parameters, balance) - } - - binding.budgetLineAmount.setTextColor(ContextCompat.getColor(requireContext(), when { - balance <= 0 -> R.color.budget_red - balance < parameters.getLowMoneyWarningAmount() -> R.color.budget_orange - else -> R.color.budget_green - })) - } - - private fun initCalendarView() { - binding.calendarView.setContent { - AppTheme { - CalendarView( - parameters = parameters, - dbAvailableFlow = viewModel.dbAvailableFlow, - forceRefreshDataFlow = viewModel.forceRefreshFlow, - selectedDateFlow = viewModel.selectDateFlow, - includeCheckedBalanceFlow = viewModel.includeCheckedBalanceFlow, - onMonthChanged = viewModel::onMonthChanged, - goBackToCurrentMonthEventFlow = viewModel.goBackToCurrentMonthEventFlow, - onDateSelected = viewModel::onSelectDate, - onDateLongClicked = viewModel::onDateLongClicked, - ) - } - } - } - - private fun initFab() { - isMenuExpended = binding.fabChoicesBackground.visibility == View.VISIBLE - - binding.fabChoicesBackground.setOnClickListener { collapseMenu() } - binding.fabChoices.setOnClickListener { - if (isMenuExpended) { - collapseMenu() - } else { - expandMenu() - } - } - - listOf(binding.fabNewExpense, binding.fabNewExpenseText).forEach { - it.setOnClickListener { - val startIntent = ExpenseEditActivity.newIntent( - context = requireContext(), - editedExpense = null, - date = viewModel.selectDateFlow.value, - ) - - requireActivity().startActivity(startIntent) - - collapseMenu() - } - } - - listOf(binding.fabNewRecurringExpense, binding.fabNewRecurringExpenseText).forEach { - it.setOnClickListener { - val startIntent = RecurringExpenseEditActivity.newIntent( - context = requireContext(), - startDate = viewModel.selectDateFlow.value, - editedExpense = null, - ) - - requireActivity().startActivity(startIntent) - - collapseMenu() - } - } - } - - private fun initRecyclerView() { - binding.expensesRecyclerView.layoutManager = LinearLayoutManager(requireContext()) - - expensesViewAdapter = ExpensesRecyclerViewAdapter(this, parameters, LocalDate.now()) { expense, checked -> - viewModel.onExpenseChecked(expense, checked) - } - binding.expensesRecyclerView.adapter = expensesViewAdapter - } - - private fun collapseMenu() { - isMenuExpended = false - menuExpandAnimation?.cancel() - menuCollapseAnimation?.cancel() - - menuCollapseAnimation = AlphaAnimation(1.0f, 0.0f) - menuCollapseAnimation?.duration = menuBackgroundAnimationDuration - menuCollapseAnimation?.fillAfter = true - menuCollapseAnimation?.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation) { - } - - override fun onAnimationEnd(animation: Animation) { - binding.fabChoicesBackground.visibility = View.GONE - binding.fabChoicesBackground.isClickable = false - - binding.fabNewRecurringExpenseContainer.isVisible = false - binding.fabNewExpenseContainer.isVisible = false - } - - override fun onAnimationRepeat(animation: Animation) { - - } - }) - - binding.fabChoicesBackground.startAnimation(menuCollapseAnimation) - binding.fabNewRecurringExpense.startAnimation(menuCollapseAnimation) - binding.fabNewRecurringExpenseText.startAnimation(menuCollapseAnimation) - binding.fabNewExpense.startAnimation(menuCollapseAnimation) - binding.fabNewExpenseText.startAnimation(menuCollapseAnimation) - } - - private fun expandMenu() { - isMenuExpended = true - menuExpandAnimation?.cancel() - menuCollapseAnimation?.cancel() - - menuExpandAnimation = AlphaAnimation(0.0f, 1.0f) - menuExpandAnimation?.duration = menuBackgroundAnimationDuration - menuExpandAnimation?.fillAfter = true - menuExpandAnimation?.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation) { - binding.fabChoicesBackground.visibility = View.VISIBLE - binding.fabChoicesBackground.isClickable = true - - binding.fabNewRecurringExpenseContainer.isVisible = true - binding.fabNewExpenseContainer.isVisible = true - } - - override fun onAnimationEnd(animation: Animation) { - - } - - override fun onAnimationRepeat(animation: Animation) { - - } - }) - - binding.fabChoicesBackground.startAnimation(menuExpandAnimation) - binding.fabNewRecurringExpense.startAnimation(menuExpandAnimation) - binding.fabNewRecurringExpenseText.startAnimation(menuExpandAnimation) - binding.fabNewExpense.startAnimation(menuExpandAnimation) - binding.fabNewExpenseText.startAnimation(menuExpandAnimation) - } - - private fun refreshRecyclerViewForDate(date: LocalDate, expenses: List) { - expensesViewAdapter.setDate(date, expenses) - - if ( expenses.isNotEmpty() ) { - binding.expensesRecyclerView.visibility = View.VISIBLE - binding.emptyExpensesRecyclerViewPlaceholder.visibility = View.GONE - } else { - binding.expensesRecyclerView.visibility = View.GONE - binding.emptyExpensesRecyclerViewPlaceholder.visibility = View.VISIBLE - } - } - - private fun refreshBalanceAndExpenseListForDate(date: LocalDate, balance: Double, maybeCheckedBalance: Double?, expenses: List) { - refreshRecyclerViewForDate(date, expenses) - updateBalanceDisplayForDay(date, balance, maybeCheckedBalance) - } - - /** - * Show a generic alert dialog telling the user an error occured while deleting recurring expense - */ - private fun showGenericRecurringDeleteErrorDialog() { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.recurring_expense_delete_error_title) - .setMessage(R.string.recurring_expense_delete_error_message) - .setNegativeButton(R.string.ok) { dialog, _ -> dialog.dismiss() } - .show() - } - - companion object { - const val ARG_SELECTED_ACCOUNT = "selected_account" - - fun newInstance(account: MainViewModel.SelectedAccount.Selected): AccountFragment { - return AccountFragment().apply { - arguments = Bundle().apply { - putParcelable(ARG_SELECTED_ACCOUNT, account) - } - } - } - } -} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/AccountViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/AccountViewModel.kt deleted file mode 100644 index ad763eb5..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/AccountViewModel.kt +++ /dev/null @@ -1,622 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.main.account - -import android.content.Context -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.benoitletondor.easybudgetapp.auth.Auth -import com.benoitletondor.easybudgetapp.auth.AuthState -import com.benoitletondor.easybudgetapp.db.DB -import com.benoitletondor.easybudgetapp.db.RestoreAction -import com.benoitletondor.easybudgetapp.db.onlineimpl.OnlineDB -import com.benoitletondor.easybudgetapp.helper.Logger -import com.benoitletondor.easybudgetapp.helper.MutableLiveFlow -import com.benoitletondor.easybudgetapp.iab.Iab -import com.benoitletondor.easybudgetapp.iab.PremiumCheckStatus -import com.benoitletondor.easybudgetapp.injection.AppModule -import com.benoitletondor.easybudgetapp.model.Expense -import com.benoitletondor.easybudgetapp.model.RecurringExpense -import com.benoitletondor.easybudgetapp.model.RecurringExpenseDeleteType -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.benoitletondor.easybudgetapp.parameters.watchShouldShowCheckedBalance -import com.benoitletondor.easybudgetapp.view.main.MainViewModel -import com.benoitletondor.easybudgetapp.view.main.account.AccountFragment.Companion.ARG_SELECTED_ACCOUNT -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import io.realm.kotlin.mongodb.exceptions.AuthException -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.lang.ref.WeakReference -import java.time.LocalDate -import java.time.YearMonth -import javax.inject.Inject - -@HiltViewModel -class AccountViewModel @Inject constructor( - private val parameters: Parameters, - private val iab: Iab, - private val auth: Auth, - savedStateHandle: SavedStateHandle, - @ApplicationContext private val appContext: Context, -): ViewModel() { - private val account = savedStateHandle.get(ARG_SELECTED_ACCOUNT) - ?: throw IllegalStateException("No ARG_SELECTED_ACCOUNT arg") - - private val dbAvailableMutableStateFlow = MutableStateFlow(DBState.Loading) - val dbAvailableFlow: StateFlow = dbAvailableMutableStateFlow - - val premiumStatusFlow: StateFlow = iab.iabStatusFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, PremiumCheckStatus.INITIALIZING) - - val shouldShowPremiumRelatedButtons: Boolean get() = when(iab.iabStatusFlow.value) { - PremiumCheckStatus.INITIALIZING, - PremiumCheckStatus.CHECKING, - PremiumCheckStatus.ERROR, - PremiumCheckStatus.NOT_PREMIUM -> false - PremiumCheckStatus.LEGACY_PREMIUM, - PremiumCheckStatus.PREMIUM_SUBSCRIBED, - PremiumCheckStatus.PRO_SUBSCRIBED -> true - } - - private val selectDateMutableStateFlow = MutableStateFlow(LocalDate.now()) - val selectDateFlow: StateFlow = selectDateMutableStateFlow - - private val goBackToCurrentMonthEventMutableFlow = MutableSharedFlow() - val goBackToCurrentMonthEventFlow: Flow = goBackToCurrentMonthEventMutableFlow - - private val expenseDeletionSuccessEventMutableFlow = MutableLiveFlow() - val expenseDeletionSuccessEventFlow: Flow = expenseDeletionSuccessEventMutableFlow - - private val expenseDeletionErrorEventMutableFlow = MutableLiveFlow() - val expenseDeletionErrorEventFlow: Flow = expenseDeletionErrorEventMutableFlow - - private val recurringExpenseDeletionProgressStateMutableFlow = MutableStateFlow(RecurringExpenseDeleteProgressState.Idle) - val recurringExpenseDeletionProgressStateFlow: Flow = recurringExpenseDeletionProgressStateMutableFlow - - private val recurringExpenseDeletionEventMutableFlow = MutableLiveFlow() - val recurringExpenseDeletionEventFlow: Flow = recurringExpenseDeletionEventMutableFlow - - private val recurringExpenseRestoreProgressStateMutableFlow = MutableStateFlow( - RecurringExpenseRestoreProgressState.Idle) - val recurringExpenseRestoreProgressStateFlow: Flow = recurringExpenseRestoreProgressStateMutableFlow - - private val recurringExpenseRestoreEventMutableFlow = MutableLiveFlow() - val recurringExpenseRestoreEventFlow: Flow = recurringExpenseRestoreEventMutableFlow - - private val startCurrentBalanceEditorEventMutableFlow = MutableLiveFlow() - val startCurrentBalanceEditorEventFlow: Flow = startCurrentBalanceEditorEventMutableFlow - - private val showGoToCurrentMonthButtonStateMutableFlow = MutableStateFlow(false) - val showGoToCurrentMonthButtonStateFlow: StateFlow = showGoToCurrentMonthButtonStateMutableFlow - - private val currentBalanceEditingErrorEventMutableFlow = MutableLiveFlow() - val currentBalanceEditingErrorEventFlow: Flow = currentBalanceEditingErrorEventMutableFlow - - private val currentBalanceEditedEventMutableFlow = MutableLiveFlow() - val currentBalanceEditedEventFlow: Flow = currentBalanceEditedEventMutableFlow - - private val currentBalanceRestoringErrorEventMutableFlow = MutableLiveFlow() - val currentBalanceRestoringErrorEventFlow: Flow = currentBalanceRestoringErrorEventMutableFlow - - private val expenseCheckedErrorEventMutableFlow = MutableLiveFlow() - val expenseCheckedErrorEventFlow: Flow = expenseCheckedErrorEventMutableFlow - - private val confirmCheckAllPastEntriesEventMutableFlow = MutableLiveFlow() - val confirmCheckAllPastEntriesEventFlow: Flow = confirmCheckAllPastEntriesEventMutableFlow - - private val checkAllPastEntriesErrorEventMutableFlow = MutableLiveFlow() - val checkAllPastEntriesErrorEventFlow: Flow = checkAllPastEntriesErrorEventMutableFlow - - private val openMonthlyReportEventMutableFlow = MutableLiveFlow() - val openMonthlyReportEventFlow: Flow = openMonthlyReportEventMutableFlow - - private val openExpenseAddEventMutableFlow = MutableLiveFlow() - val openExpenseAddEventFlow: Flow = openExpenseAddEventMutableFlow - - private val openManageAccountEventMutableFlow = MutableLiveFlow() - val openManageAccountEventFlow: Flow = openManageAccountEventMutableFlow - - private val forceRefreshMutableFlow = MutableSharedFlow() - val forceRefreshFlow: Flow = forceRefreshMutableFlow - - private val showManageAccountMenuItemMutableFlow = MutableStateFlow(false) - val showManageAccountMenuItem: StateFlow = showManageAccountMenuItemMutableFlow - - private var changesWatchingJob: Job? = null - - val includeCheckedBalanceFlow = premiumStatusFlow - .mapNotNull { when(it) { - PremiumCheckStatus.INITIALIZING, - PremiumCheckStatus.CHECKING -> null - PremiumCheckStatus.ERROR, - PremiumCheckStatus.NOT_PREMIUM -> false - PremiumCheckStatus.LEGACY_PREMIUM, - PremiumCheckStatus.PREMIUM_SUBSCRIBED, - PremiumCheckStatus.PRO_SUBSCRIBED -> true - } } - .distinctUntilChanged() - .combine(parameters.watchShouldShowCheckedBalance()) { isPremium, shouldShowCheckedBalance -> - isPremium && shouldShowCheckedBalance - } - .stateIn(viewModelScope, SharingStarted.Eagerly, false) - - val selectedDateDataFlow = combine( - selectDateMutableStateFlow, - includeCheckedBalanceFlow, - forceRefreshMutableFlow - .onStart { - emit(Unit) - }, - ) { date, includeCheckedBalance, _ -> - val (balance, expenses, checkedBalance) = withContext(Dispatchers.Default) { - Triple( - getBalanceForDay(date), - awaitDB().getExpensesForDay(date), - if (includeCheckedBalance) { - getCheckedBalanceForDay(date) - } else { - null - }, - ) - } - - SelectedDateExpensesData(date, balance, checkedBalance, expenses) - } - .catch { e -> - Logger.error("Error while getting selected date data", e) - } - .stateIn(viewModelScope, SharingStarted.Eagerly, SelectedDateExpensesData(selectDateMutableStateFlow.value, 0.0, null, emptyList())) - - init { - loadDB() - } - - private fun loadDB() { - viewModelScope.launch { - dbAvailableMutableStateFlow.value = DBState.Loading - showManageAccountMenuItemMutableFlow.value = false - - try { - val db = when(account) { - MainViewModel.SelectedAccount.Selected.Offline -> AppModule.provideDB(appContext) - is MainViewModel.SelectedAccount.Selected.Online -> { - val currentUser = (auth.state.value as? AuthState.Authenticated)?.currentUser ?: throw IllegalStateException("User is not authenticated") - - val onlineDb = withContext(Dispatchers.IO) { - AppModule.provideSyncedOnlineDBOrThrow( - currentUser = currentUser, - accountId = account.accountId, - accountSecret = account.accountSecret, - ) - } - - showManageAccountMenuItemMutableFlow.value = true - onlineDb - } - } - - currentDBRef = WeakReference(db) - changesWatchingJob?.cancel() - changesWatchingJob = launch { - db.onChangeFlow - .collect { - forceRefreshMutableFlow.emit(Unit) - } - } - - dbAvailableMutableStateFlow.value = DBState.Loaded(db) - } catch (e: Exception) { - if (e is CancellationException) throw e - - Logger.error("Error while loading DB", e) - - dbAvailableMutableStateFlow.value = DBState.Error(e) - } - } - } - - override fun onCleared() { - val currentDB = currentDBRef?.get() - val dbState = dbAvailableMutableStateFlow.value as? DBState.Loaded - if (currentDB != null && dbState != null && dbState.db == currentDB) { - currentDBRef = null - } - - try { - (dbState?.db as? OnlineDB)?.close() - } catch (e: Exception) { - Logger.warning("Error while trying to close online DB when clearing, continuing") - } - - super.onCleared() - } - - fun onRetryLoadingButtonPressed() { - val e = (dbAvailableMutableStateFlow.value as? DBState.Error)?.error - if (e != null && e is AuthException) { - viewModelScope.launch { - dbAvailableMutableStateFlow.value = DBState.Loading - - try { - Logger.debug("Refreshing user tokens") - auth.refreshUserTokens() - } catch (e: Exception) { - if (e is CancellationException) throw e - - Logger.error("Error while force refreshing user token", e) - } - - loadDB() - } - } else { - loadDB() - } - } - - fun onMonthlyReportButtonPressed() { - viewModelScope.launch { - openMonthlyReportEventMutableFlow.emit(Unit) - } - } - - sealed class RecurringExpenseDeleteProgressState { - data object Idle : RecurringExpenseDeleteProgressState() - class Deleting(val expense: Expense): RecurringExpenseDeleteProgressState() - } - - sealed class RecurringExpenseDeletionEvent { - class ErrorRecurringExpenseDeleteNotAssociated(val expense: Expense): RecurringExpenseDeletionEvent() - class ErrorCantDeleteBeforeFirstOccurrence(val expense: Expense): RecurringExpenseDeletionEvent() - class ErrorIO(val expense: Expense): RecurringExpenseDeletionEvent() - class Success(val recurringExpense: RecurringExpense, val restoreAction: RestoreAction): RecurringExpenseDeletionEvent() - } - - sealed class RecurringExpenseRestoreProgressState { - data object Idle : RecurringExpenseRestoreProgressState() - class Restoring(val recurringExpense: RecurringExpense): RecurringExpenseRestoreProgressState() - } - - sealed class RecurringExpenseRestoreEvent { - class ErrorIO(val recurringExpense: RecurringExpense): RecurringExpenseRestoreEvent() - class Success(val recurringExpense: RecurringExpense): RecurringExpenseRestoreEvent() - } - - fun onDeleteExpenseClicked(expense: Expense) { - viewModelScope.launch { - try { - val restoreAction = withContext(Dispatchers.IO) { - awaitDB().deleteExpense(expense) - } - - expenseDeletionSuccessEventMutableFlow.emit(ExpenseDeletionSuccessData( - expense, - restoreAction, - )) - } catch (t: Throwable) { - Logger.error("Error while deleting expense", t) - expenseDeletionErrorEventMutableFlow.emit(expense) - } - } - } - - fun onExpenseDeletionCancelled(restoreAction: RestoreAction) { - viewModelScope.launch { - try { - withContext(Dispatchers.IO) { - restoreAction() - } - } catch (t: Throwable) { - Logger.error("Error while restoring expense", t) - } - } - } - - fun onDeleteRecurringExpenseClicked(expense: Expense, deleteType: RecurringExpenseDeleteType) { - viewModelScope.launch { - recurringExpenseDeletionProgressStateMutableFlow.value = RecurringExpenseDeleteProgressState.Deleting(expense) - - try { - val associatedRecurringExpense = expense.associatedRecurringExpense - if( associatedRecurringExpense == null ) { - recurringExpenseDeletionEventMutableFlow.emit(RecurringExpenseDeletionEvent.ErrorRecurringExpenseDeleteNotAssociated(expense)) - return@launch - } - - val firstOccurrenceError = withContext(Dispatchers.Default) { - deleteType == RecurringExpenseDeleteType.TO && !awaitDB().hasExpensesForRecurringExpenseBeforeDate(associatedRecurringExpense.recurringExpense, expense.date) - } - - if ( firstOccurrenceError ) { - recurringExpenseDeletionEventMutableFlow.emit(RecurringExpenseDeletionEvent.ErrorCantDeleteBeforeFirstOccurrence(expense)) - return@launch - } - - val restoreAction: RestoreAction? = withContext(Dispatchers.IO) { - when (deleteType) { - RecurringExpenseDeleteType.ALL -> { - try { - awaitDB().deleteRecurringExpense(associatedRecurringExpense.recurringExpense) - } catch (e: Exception) { - if (e is CancellationException) throw e - - Logger.error("Error while deleting recurring expense", e) - null - } - } - RecurringExpenseDeleteType.FROM -> { - try { - awaitDB().deleteAllExpenseForRecurringExpenseAfterDate(associatedRecurringExpense.recurringExpense, expense.date) - } catch (e: Exception) { - if (e is CancellationException) throw e - - Logger.error("Error while deleting recurring expense from", e) - null - } - } - RecurringExpenseDeleteType.TO -> { - try { - awaitDB().deleteAllExpenseForRecurringExpenseBeforeDate(associatedRecurringExpense.recurringExpense, expense.date) - } catch (e: Exception) { - if (e is CancellationException) throw e - - Logger.error("Error while deleting recurring expense to", e) - null - } - } - RecurringExpenseDeleteType.ONE -> { - try { - awaitDB().deleteExpense(expense) - } catch (e: Exception) { - if (e is CancellationException) throw e - - Logger.error("Error while deleting recurring expense one", e) - null - } - } - } - } - - if( restoreAction == null ) { - recurringExpenseDeletionEventMutableFlow.emit(RecurringExpenseDeletionEvent.ErrorIO(expense)) - return@launch - } - - recurringExpenseDeletionEventMutableFlow.emit(RecurringExpenseDeletionEvent.Success(associatedRecurringExpense.recurringExpense, restoreAction)) - } finally { - recurringExpenseDeletionProgressStateMutableFlow.value = RecurringExpenseDeleteProgressState.Idle - } - } - - } - - fun onRestoreRecurringExpenseClicked(recurringExpense: RecurringExpense, restoreAction: RestoreAction) { - viewModelScope.launch { - recurringExpenseRestoreProgressStateMutableFlow.value = RecurringExpenseRestoreProgressState.Restoring(recurringExpense) - - try { - restoreAction() - recurringExpenseRestoreEventMutableFlow.emit(RecurringExpenseRestoreEvent.Success(recurringExpense)) - } catch (e: Exception) { - recurringExpenseRestoreEventMutableFlow.emit(RecurringExpenseRestoreEvent.ErrorIO(recurringExpense)) - } finally { - recurringExpenseRestoreProgressStateMutableFlow.value = RecurringExpenseRestoreProgressState.Idle - } - } - } - - fun onIabStatusChanged() { - viewModelScope.launch { - forceRefreshMutableFlow.emit(Unit) - } - } - - fun onAdjustCurrentBalanceClicked() { - viewModelScope.launch { - val balance = withContext(Dispatchers.Default) { - -awaitDB().getBalanceForDay(LocalDate.now()) - } - - startCurrentBalanceEditorEventMutableFlow.emit(balance) - } - } - - fun onNewBalanceSelected(newBalance: Double, balanceExpenseTitle: String) { - viewModelScope.launch { - try { - val currentBalance = withContext(Dispatchers.Default) { - -awaitDB().getBalanceForDay(LocalDate.now()) - } - - if (newBalance == currentBalance) { - // Nothing to do, balance hasn't change - return@launch - } - - val diff = newBalance - currentBalance - - // Look for an existing balance for the day - val existingExpense = withContext(Dispatchers.Default) { - awaitDB().getExpensesForDay(LocalDate.now()).find { it.title == balanceExpenseTitle } - } - - if (existingExpense != null) { // If the adjust balance exists, just add the diff and persist it - val newExpense = withContext(Dispatchers.Default) { - awaitDB().persistExpense(existingExpense.copy(amount = existingExpense.amount - diff)) - } - - currentBalanceEditedEventMutableFlow.emit(BalanceAdjustedData(newExpense, diff, newBalance)) - } else { // If no adjust balance yet, create a new one - val persistedExpense = withContext(Dispatchers.Default) { - awaitDB().persistExpense(Expense(balanceExpenseTitle, -diff, LocalDate.now(), true)) - } - - currentBalanceEditedEventMutableFlow.emit(BalanceAdjustedData(persistedExpense, diff, newBalance)) - } - } catch (e: Exception) { - Logger.error("Error while editing balance", e) - currentBalanceEditingErrorEventMutableFlow.emit(e) - } - } - } - - fun onCurrentBalanceEditedCancelled(expense: Expense, diff: Double) { - viewModelScope.launch { - try { - withContext(Dispatchers.Default) { - if( expense.amount + diff == 0.0 ) { - awaitDB().deleteExpense(expense) - } else { - val newExpense = expense.copy(amount = expense.amount + diff) - awaitDB().persistExpense(newExpense) - } - } - } catch (e: Exception) { - Logger.error("Error while restoring balance", e) - currentBalanceRestoringErrorEventMutableFlow.emit(e) - } - } - } - - fun onSelectDate(date: LocalDate) { - selectDateMutableStateFlow.value = date - } - - fun onDateLongClicked(date: LocalDate) { - viewModelScope.launch { - openExpenseAddEventMutableFlow.emit(date) - } - } - - fun onCurrencySelected() { - viewModelScope.launch { - forceRefreshMutableFlow.emit(Unit) - } - } - - private suspend fun getBalanceForDay(date: LocalDate): Double { - var balance = 0.0 // Just to keep a positive number if balance == 0 - balance -= awaitDB().getBalanceForDay(date) - - return balance - } - - private suspend fun getCheckedBalanceForDay(date: LocalDate): Double { - var balance = 0.0 // Just to keep a positive number if balance == 0 - balance -= awaitDB().getCheckedBalanceForDay(date) - - return balance - } - - fun onExpenseChecked(expense: Expense, checked: Boolean) { - viewModelScope.launch { - try { - withContext(Dispatchers.Default) { - awaitDB().persistExpense(expense.copy(checked = checked)) - } - } catch (e: Exception) { - Logger.error("Error while checking expense", e) - expenseCheckedErrorEventMutableFlow.emit(e) - } - } - } - - fun onMonthChanged(yearMonth: YearMonth) { - showGoToCurrentMonthButtonStateMutableFlow.value = yearMonth != YearMonth.now() - } - - fun onGoBackToCurrentMonthButtonPressed() { - viewModelScope.launch { - selectDateMutableStateFlow.value = LocalDate.now() - goBackToCurrentMonthEventMutableFlow.emit(Unit) - } - } - - fun onShowCheckedBalanceChanged() { - viewModelScope.launch { - forceRefreshMutableFlow.emit(Unit) - } - } - - fun onCheckAllPastEntriesPressed() { - viewModelScope.launch { - confirmCheckAllPastEntriesEventMutableFlow.emit(Unit) - } - } - - fun onCheckAllPastEntriesConfirmPressed() { - viewModelScope.launch { - try { - withContext(Dispatchers.Default) { - awaitDB().markAllEntriesAsChecked(LocalDate.now()) - } - } catch (e: Exception) { - Logger.error("Error while checking all past entries", e) - checkAllPastEntriesErrorEventMutableFlow.emit(e) - } - } - } - - fun onLowMoneyWarningThresholdChanged() { - viewModelScope.launch { - forceRefreshMutableFlow.emit(Unit) - } - } - - fun onManageAccountButtonPressed() { - viewModelScope.launch { - (account as? MainViewModel.SelectedAccount.Selected.Online)?.let { - openManageAccountEventMutableFlow.emit(it) - } - } - } - - private suspend fun awaitDB() = dbAvailableMutableStateFlow.filterIsInstance().first().db - - sealed class DBState { - data object Loading : DBState() - class Loaded(val db: DB) : DBState() - class Error(val error: Exception) : DBState() - } - - companion object { - private var currentDBRef: WeakReference? = null - - fun getCurrentDB(): DB? = currentDBRef?.get() - } -} - -data class SelectedDateExpensesData(val date: LocalDate, val balance: Double, val checkedBalance: Double?, val expenses: List) -data class ExpenseDeletionSuccessData(val deletedExpense: Expense, val restoreAction: RestoreAction) -data class BalanceAdjustedData(val balanceExpense: Expense, val diffWithOldBalance: Double, val newBalance: Double) \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/ExpensesRecyclerViewAdapter.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/ExpensesRecyclerViewAdapter.kt deleted file mode 100644 index b011a9ef..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/ExpensesRecyclerViewAdapter.kt +++ /dev/null @@ -1,213 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.main.account - -import android.content.Intent -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.CheckBox -import android.widget.ImageView -import android.widget.TextView -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.recyclerview.widget.RecyclerView -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.helper.CurrencyHelper -import com.benoitletondor.easybudgetapp.helper.toFormattedString -import com.benoitletondor.easybudgetapp.model.Expense -import com.benoitletondor.easybudgetapp.model.RecurringExpenseDeleteType -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.benoitletondor.easybudgetapp.view.expenseedit.ExpenseEditActivity -import com.benoitletondor.easybudgetapp.view.main.MainActivity -import com.benoitletondor.easybudgetapp.view.recurringexpenseadd.RecurringExpenseEditActivity -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import java.time.LocalDate - -/** - * Recycler view adapter to display expenses for a given date - * - * @author Benoit LETONDOR - */ -class ExpensesRecyclerViewAdapter( - private val fragment: Fragment, - private val parameters: Parameters, - private var date: LocalDate, - private val onExpenseCheckedListener: (Expense, Boolean) -> Unit, -) : RecyclerView.Adapter() { - - private var expenses = mutableListOf() - private var isUserPremium = false - - fun setUserPremium(isPremium: Boolean) { - if (isPremium == isUserPremium) { - return - } - - isUserPremium = isPremium - notifyDataSetChanged() - } - - /** - * Set a new date and data to display - */ - fun setDate(date: LocalDate, expenses: List) { - this.date = date - this.expenses.clear() - this.expenses.addAll(expenses) - notifyDataSetChanged() - } - -// ------------------------------------------> - - override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): ViewHolder { - val v = LayoutInflater.from(viewGroup.context).inflate(R.layout.recycleview_expense_cell, viewGroup, false) - return ViewHolder(v) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, i: Int) { - val expense = expenses[i] - - viewHolder.expenseTitleTextView.text = expense.title - viewHolder.expenseAmountTextView.text = CurrencyHelper.getFormattedCurrencyString(parameters, -expense.amount) - viewHolder.expenseAmountTextView.setTextColor(ContextCompat.getColor(viewHolder.view.context, if (expense.isRevenue()) R.color.budget_green else R.color.budget_red)) - viewHolder.recurringIndicator.visibility = if (expense.isRecurring()) View.VISIBLE else View.GONE - viewHolder.positiveIndicator.setImageResource(if (expense.isRevenue()) R.drawable.ic_label_green else R.drawable.ic_label_red) - viewHolder.checkedCheckBox.visibility = if( isUserPremium ) { View.VISIBLE } else { View.GONE } - viewHolder.checkedCheckBox.setOnCheckedChangeListener { _, checked -> - if( checked != expense.checked ) { - onExpenseCheckedListener(expense, checked) - } - } - viewHolder.checkedCheckBox.isChecked = expense.checked - - if (expense.isRecurring()) { - viewHolder.recurringIndicatorTextview.text = expense.associatedRecurringExpense!!.recurringExpense.type.toFormattedString(viewHolder.view.context) - } - - val onClickListener = View.OnClickListener { - if (expense.isRecurring()) { - val builder = MaterialAlertDialogBuilder(fragment.requireContext()) - builder.setTitle(if (expense.isRevenue()) R.string.dialog_edit_recurring_income_title else R.string.dialog_edit_recurring_expense_title) - builder.setItems(if (expense.isRevenue()) R.array.dialog_edit_recurring_income_choices else R.array.dialog_edit_recurring_expense_choices) { _, which -> - when (which) { - // Edit this one - 0 -> { - val startIntent = ExpenseEditActivity.newIntent( - context = viewHolder.view.context, - editedExpense = expense, - date = expense.date, - ) - - fragment.requireActivity().startActivity(startIntent) - } - // Edit this one and following ones - 1 -> { - val startIntent = RecurringExpenseEditActivity.newIntent( - context = viewHolder.view.context, - editedExpense = expense, - startDate = expense.date, - ) - - fragment.requireActivity().startActivity(startIntent) - } - // Delete this one - 2 -> { - // Send notification to inform views that this expense has been deleted - val intent = Intent(MainActivity.INTENT_RECURRING_EXPENSE_DELETED) - intent.putExtra("expense", expense) - intent.putExtra("deleteType", RecurringExpenseDeleteType.ONE.value) - LocalBroadcastManager.getInstance(fragment.requireContext()).sendBroadcast(intent) - } - // Delete from - 3 -> { - // Send notification to inform views that this expense has been deleted - val intent = Intent(MainActivity.INTENT_RECURRING_EXPENSE_DELETED) - intent.putExtra("expense", expense) - intent.putExtra("deleteType", RecurringExpenseDeleteType.FROM.value) - LocalBroadcastManager.getInstance(fragment.requireContext()).sendBroadcast(intent) - } - // Delete up to - 4 -> { - // Send notification to inform views that this expense has been deleted - val intent = Intent(MainActivity.INTENT_RECURRING_EXPENSE_DELETED) - intent.putExtra("expense", expense) - intent.putExtra("deleteType", RecurringExpenseDeleteType.TO.value) - LocalBroadcastManager.getInstance(fragment.requireContext()).sendBroadcast(intent) - } - // Delete all - 5 -> { - // Send notification to inform views that this expense has been deleted - val intent = Intent(MainActivity.INTENT_RECURRING_EXPENSE_DELETED) - intent.putExtra("expense", expense) - intent.putExtra("deleteType", RecurringExpenseDeleteType.ALL.value) - LocalBroadcastManager.getInstance(fragment.requireContext()).sendBroadcast(intent) - } - } - } - builder.show() - } else { - val builder = MaterialAlertDialogBuilder(fragment.requireContext()) - builder.setTitle(if (expense.isRevenue()) R.string.dialog_edit_income_title else R.string.dialog_edit_expense_title) - builder.setItems(if (expense.isRevenue()) R.array.dialog_edit_income_choices else R.array.dialog_edit_expense_choices) { _, which -> - when (which) { - 0 // Edit expense - -> { - val startIntent = ExpenseEditActivity.newIntent( - context = viewHolder.view.context, - editedExpense = expense, - date = expense.date, - ) - - fragment.requireActivity().startActivity(startIntent) - } - 1 // Delete - -> { - // Send notification to inform views that this expense has been deleted - val intent = Intent(MainActivity.INTENT_EXPENSE_DELETED) - intent.putExtra("expense", expense) - LocalBroadcastManager.getInstance(fragment.requireContext()).sendBroadcast(intent) - } - } - } - builder.show() - } - - } - - viewHolder.view.setOnClickListener(onClickListener) - - viewHolder.view.setOnLongClickListener { v -> - onClickListener.onClick(v) - true - } - } - - override fun getItemCount(): Int = expenses.size - -// -------------------------------------------> - - class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) { - val expenseTitleTextView: TextView = view.findViewById(R.id.expense_title) - val expenseAmountTextView: TextView = view.findViewById(R.id.expense_amount) - val recurringIndicator: ViewGroup = view.findViewById(R.id.recurring_indicator) - val recurringIndicatorTextview: TextView = view.findViewById(R.id.recurring_indicator_textview) - val positiveIndicator: ImageView = view.findViewById(R.id.positive_indicator) - val checkedCheckBox: CheckBox = view.findViewById(R.id.expense_checked) - } -} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/accountselector/AccountSelectorFragment.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/accountselector/AccountSelectorFragment.kt deleted file mode 100644 index 1c2af2bc..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/accountselector/AccountSelectorFragment.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.main.accountselector - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.viewModels -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.databinding.FragmentAccountSelectorBinding -import com.benoitletondor.easybudgetapp.helper.launchCollect -import com.benoitletondor.easybudgetapp.helper.viewLifecycleScope -import com.benoitletondor.easybudgetapp.theme.AppTheme -import com.benoitletondor.easybudgetapp.view.main.MainActivity -import com.benoitletondor.easybudgetapp.view.main.accountselector.view.AccountsView -import com.benoitletondor.easybudgetapp.view.main.createaccount.CreateAccountActivity -import com.benoitletondor.easybudgetapp.view.main.login.LoginActivity -import com.benoitletondor.easybudgetapp.view.settings.SettingsActivity -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class AccountSelectorFragment : BottomSheetDialogFragment() { - private var binding: FragmentAccountSelectorBinding? = null - - private val viewModel: AccountSelectorViewModel by viewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentAccountSelectorBinding.inflate(inflater, container, false) - this.binding = binding - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding?.root?.setContent { - AppTheme { - AccountsView(viewModel) - } - } - - viewLifecycleScope.launchCollect(viewModel.eventFlow) { event -> - when(event) { - is AccountSelectorViewModel.Event.AccountSelected -> { - (activity as? MainActivity)?.onAccountSelectedFromBottomSheet(event.account) - dismiss() - } - AccountSelectorViewModel.Event.OpenProScreen -> { - activity?.let { activity -> - val startIntent = Intent(activity, SettingsActivity::class.java) - startIntent.putExtra(SettingsActivity.SHOW_PRO_INTENT_KEY, true) - activity.startActivity(startIntent) - } - dismiss() - } - is AccountSelectorViewModel.Event.OpenLoginScreen -> { - activity?.let { - it.startActivity(LoginActivity.newIntent(it, shouldDismissAfterAuth = event.shouldDismissAfterAuth)) - } - } - AccountSelectorViewModel.Event.OpenCreateAccountScreen -> { - activity?.let { - it.startActivity(Intent(it, CreateAccountActivity::class.java)) - } - } - is AccountSelectorViewModel.Event.ErrorAcceptingInvitation -> { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.account_invitation_error_accepting_title) - .setMessage(getString(R.string.account_invitation_error_accepting_message, event.error.localizedMessage)) - .setPositiveButton(R.string.ok) { dialog, _ -> - dialog.dismiss() - } - .show() - } - is AccountSelectorViewModel.Event.ErrorRejectingInvitation -> { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.account_invitation_error_rejecting_title) - .setMessage(getString(R.string.account_invitation_error_rejecting_message, event.error.localizedMessage)) - .setPositiveButton(R.string.ok) { dialog, _ -> - dialog.dismiss() - } - .show() - } - AccountSelectorViewModel.Event.InvitationAccepted -> { - Toast.makeText(requireContext(), R.string.account_invitation_accepted_message, Toast.LENGTH_LONG).show() - } - AccountSelectorViewModel.Event.InvitationRejected -> { - Toast.makeText(requireContext(), R.string.account_invitation_rejected_message, Toast.LENGTH_LONG).show() - } - } - } - } - - override fun onDestroyView() { - binding = null - super.onDestroyView() - } -} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/loading/LoadingFragment.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/loading/LoadingFragment.kt deleted file mode 100644 index 37628e2e..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/loading/LoadingFragment.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.main.loading - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import com.benoitletondor.easybudgetapp.databinding.FragmentAccountLoadingBinding -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class LoadingFragment : Fragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = FragmentAccountLoadingBinding.inflate(inflater, container, false).root - -} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/manageaccount/ManageAccountActivity.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/manageaccount/ManageAccountActivity.kt deleted file mode 100644 index d6055f1d..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/manageaccount/ManageAccountActivity.kt +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.main.manageaccount - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.MenuItem -import android.widget.Toast -import androidx.activity.viewModels -import androidx.compose.runtime.getValue -import androidx.compose.runtime.collectAsState -import androidx.lifecycle.lifecycleScope -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.databinding.ActivityCreateAccountBinding -import com.benoitletondor.easybudgetapp.helper.BaseActivity -import com.benoitletondor.easybudgetapp.helper.launchCollect -import com.benoitletondor.easybudgetapp.theme.AppTheme -import com.benoitletondor.easybudgetapp.view.main.MainViewModel -import com.benoitletondor.easybudgetapp.view.main.manageaccount.view.ContentView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class ManageAccountActivity : BaseActivity() { - private val viewModel: ManageAccountViewModel by viewModels() - - override fun createBinding(): ActivityCreateAccountBinding = ActivityCreateAccountBinding.inflate(layoutInflater) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - binding.createAccountComposeView.setContent { - AppTheme { - val state by viewModel.stateFlow.collectAsState() - - ContentView( - state = state, - onUpdateAccountNameClicked = viewModel::onUpdateAccountNameClicked, - onInvitationDeleteConfirmed = viewModel::onInvitationDeleteConfirmed, - onRetryButtonClicked = viewModel::onRetryButtonClicked, - onLeaveAccountConfirmed = viewModel::onLeaveAccountConfirmed, - onInviteEmailToAccount = viewModel::onInviteEmailToAccount, - onDeleteAccountConfirmed = viewModel::onDeleteAccountConfirmed, - ) - } - } - - lifecycleScope.launchCollect(viewModel.eventFlow) { event -> - when(event) { - ManageAccountViewModel.Event.AccountLeft -> Toast.makeText(this, R.string.account_management_account_left_confirmation, Toast.LENGTH_LONG).show() - ManageAccountViewModel.Event.AccountNameUpdated -> Toast.makeText(this, R.string.account_management_account_name_updated_confirmation, Toast.LENGTH_LONG).show() - is ManageAccountViewModel.Event.ErrorDeletingInvitation -> MaterialAlertDialogBuilder(this) - .setTitle(R.string.account_management_error_title) - .setMessage(getString(R.string.account_management_error_deleting_invitation, event.error.localizedMessage)) - .setPositiveButton(R.string.ok) { dialog, _ -> - dialog.dismiss() - } - .show() - is ManageAccountViewModel.Event.ErrorUpdatingAccountName -> MaterialAlertDialogBuilder(this) - .setTitle(R.string.account_management_error_title) - .setMessage(getString(R.string.account_management_error_updating_name, event.error.localizedMessage)) - .setPositiveButton(R.string.ok) { dialog, _ -> - dialog.dismiss() - } - .show() - is ManageAccountViewModel.Event.ErrorWhileInviting -> MaterialAlertDialogBuilder(this) - .setTitle(R.string.account_management_error_title) - .setMessage(getString(R.string.account_management_error_sending_invitation, event.error.localizedMessage)) - .setPositiveButton(R.string.ok) { dialog, _ -> - dialog.dismiss() - } - .show() - is ManageAccountViewModel.Event.ErrorWhileLeavingAccount -> MaterialAlertDialogBuilder(this) - .setTitle(R.string.account_management_error_title) - .setMessage(getString(R.string.account_management_error_leaving_account, event.error.localizedMessage)) - .setPositiveButton(R.string.ok) { dialog, _ -> - dialog.dismiss() - } - .show() - ManageAccountViewModel.Event.Finish -> finish() - is ManageAccountViewModel.Event.InvitationDeleted -> Toast.makeText(this, R.string.account_management_invitation_revoked, Toast.LENGTH_LONG).show() - is ManageAccountViewModel.Event.InvitationSent -> Toast.makeText(this, R.string.account_management_invitation_sent, Toast.LENGTH_LONG).show() - ManageAccountViewModel.Event.AccountDeleted -> Toast.makeText(this, R.string.account_management_account_deleted, Toast.LENGTH_LONG).show() - is ManageAccountViewModel.Event.ErrorWhileDeletingAccount -> MaterialAlertDialogBuilder(this) - .setTitle(R.string.account_management_error_title) - .setMessage(getString(R.string.account_management_error_deleting_account, event.error.localizedMessage)) - .setPositiveButton(R.string.ok) { dialog, _ -> - dialog.dismiss() - } - .show() - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - - if (id == android.R.id.home) { - finish() - return true - } - - return super.onOptionsItemSelected(item) - } - - companion object { - const val SELECTED_ACCOUNT_EXTRA = "selectedAccount" - - fun newIntent( - context: Context, - selectedAccount: MainViewModel.SelectedAccount.Selected.Online, - ): Intent { - return Intent(context, ManageAccountActivity::class.java).apply { - putExtra(SELECTED_ACCOUNT_EXTRA, selectedAccount) - } - } - } -} - -enum class LoadingKind { - LOADING_DATA, - DELETING_INVITATION, - SENDING_INVITATION, - UPDATING_NAME, - DELETING_ACCOUNT, - LEAVING_ACCOUNT, -} - diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/DBLoadingErrorView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/DBLoadingErrorView.kt new file mode 100644 index 00000000..7ebee952 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/DBLoadingErrorView.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.main.subviews + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@Composable +fun DBLoadingErrorView( + error: Throwable, + onRetryButtonClicked: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.calendar_month_loading_error_title), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.account_error_loading_message, error.localizedMessage ?: "No error message"), + fontSize = 16.sp, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Button( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = onRetryButtonClicked, + ) { + Text(stringResource(R.string.manage_account_error_cta)) + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/ExpensesView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/ExpensesView.kt new file mode 100644 index 00000000..0aa6a8d0 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/ExpensesView.kt @@ -0,0 +1,328 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.main.subviews + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.compose.components.LoadingView +import com.benoitletondor.easybudgetapp.helper.CurrencyHelper +import com.benoitletondor.easybudgetapp.helper.toFormattedString +import com.benoitletondor.easybudgetapp.model.Expense +import com.benoitletondor.easybudgetapp.view.main.MainViewModel +import kotlinx.coroutines.flow.StateFlow +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Currency +import java.util.Locale + +@Composable +fun ColumnScope.ExpensesView( + dayDataFlow: StateFlow, + lowMoneyAmountWarningFlow: StateFlow, + userCurrencyFlow: StateFlow, + showExpensesCheckBoxFlow: StateFlow, + onExpenseCheckedChange: (Expense, Boolean) -> Unit, + onExpensePressed: (Expense) -> Unit, + onExpenseLongPressed: (Expense) -> Unit, +) { + val dataForDay by dayDataFlow.collectAsState() + val userCurrency by userCurrencyFlow.collectAsState() + + when(val dayData = dataForDay) { + is MainViewModel.SelectedDateExpensesData.DataAvailable -> { + BalanceView( + date = dayData.date, + balance = dayData.balance, + checkedBalance = dayData.checkedBalance, + userCurrency = userCurrency, + lowMoneyAmountWarningFlow = lowMoneyAmountWarningFlow, + ) + + ExpensesList( + expenses = dayData.expenses, + userCurrency = userCurrency, + showExpensesCheckBoxFlow = showExpensesCheckBoxFlow, + onExpenseCheckedChange = onExpenseCheckedChange, + onExpensePressed = onExpensePressed, + onExpenseLongPressed = onExpenseLongPressed, + ) + } + MainViewModel.SelectedDateExpensesData.NoDataAvailable -> { + LoadingView() + } + } +} + +@Composable +private fun BalanceView( + date: LocalDate, + balance: Double, + checkedBalance: Double?, + userCurrency: Currency, + lowMoneyAmountWarningFlow: StateFlow, +) { + val context = LocalContext.current + val balanceDateFormatter = remember(key1 = Locale.getDefault()) { + DateTimeFormatter.ofPattern(context.getString(R.string.account_balance_date_format), Locale.getDefault()) + } + val lowMoneyAmountWarning by lowMoneyAmountWarningFlow.collectAsState() + + Row( + modifier = Modifier + .fillMaxWidth() + .background(color = colorResource(R.color.budget_line_background_color)) + .padding(horizontal = 15.dp, vertical = 3.dp), + horizontalArrangement = Arrangement.Center, + ) { + val formattedDate = remember(key1 = date, key2 = balanceDateFormatter) { + context.getString(R.string.account_balance_format, balanceDateFormatter.format(date)).let { + // FIXME it's ugly!! + if (it.endsWith(".:")) { + return@let it.substring(0, it.length - 2) + ":" // Remove . at the end of the month (ex: nov.: -> nov:) + } else if (it.endsWith(". :")) { + return@let it.substring(0, it.length - 3) + " :" // Remove . at the end of the month (ex: nov. : -> nov :) + } else { + return@let it + } + } + } + + Text( + text = formattedDate, + fontSize = 14.sp, + color = colorResource(R.color.primary_text), + ) + + Spacer(modifier = Modifier.width(4.dp)) + + val checkedBalanceString = remember(key1 = balance, key2 = checkedBalance, key3 = userCurrency) { + if (checkedBalance != null) { + context.getString( + R.string.account_balance_checked_format, + CurrencyHelper.getFormattedCurrencyString(userCurrency, balance), + CurrencyHelper.getFormattedCurrencyString(userCurrency, checkedBalance), + ) + } else { + CurrencyHelper.getFormattedCurrencyString(userCurrency, balance) + } + } + + Text( + text = checkedBalanceString, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + color = when { + balance <= 0 -> colorResource(R.color.budget_red) + balance < lowMoneyAmountWarning -> colorResource(R.color.budget_orange) + else -> colorResource(R.color.budget_green) + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +private fun ExpensesList( + expenses: List, + userCurrency: Currency, + showExpensesCheckBoxFlow: StateFlow, + onExpenseCheckedChange: (Expense, Boolean) -> Unit, + onExpensePressed: (Expense) -> Unit, + onExpenseLongPressed: (Expense) -> Unit, +) { + if (expenses.isEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 15.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + modifier = Modifier.alpha(0.6f), + painter = painterResource(R.drawable.ic_wallet), + contentDescription = null, + ) + + Spacer(modifier = Modifier.height(5.dp)) + + Text( + text = stringResource(R.string.no_expense_for_today), + fontSize = 14.sp, + color = colorResource(R.color.secondary_text), + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize(), + ) { + items( + count = expenses.size, + ) { index -> + val expense = expenses[index] + + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { onExpensePressed(expense) }, + onLongClick = { onExpenseLongPressed(expense) }, + ) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val context = LocalContext.current + + Image( + modifier = Modifier.size(30.dp), + painter = painterResource(if (expense.isRevenue()) R.drawable.ic_label_green else R.drawable.ic_label_red), + contentDescription = null + ) + + Spacer(modifier = Modifier.width(20.dp)) + + Column( + modifier = Modifier + .weight(1f) + ) { + Text( + text = expense.title, + fontSize = 14.sp, + maxLines = 2, + color = colorResource(R.color.primary_text), + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = CurrencyHelper.getFormattedCurrencyString(userCurrency, -expense.amount), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + color = colorResource(if (expense.isRevenue()) R.color.budget_green else R.color.budget_red), + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.width(10.dp)) + + if (expense.isRecurring()) { + Column( + modifier = Modifier.width(60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(R.drawable.ic_autorenew_grey_26dp), + contentDescription = null, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = TextStyle( + platformStyle = PlatformTextStyle( + includeFontPadding = false, + ), + ), + text = expense.associatedRecurringExpense!!.recurringExpense.type.toFormattedString(context), + fontSize = 9.sp, + color = colorResource(R.color.secondary_text), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + val showCheckBox by showExpensesCheckBoxFlow.collectAsState() + if (showCheckBox) { + Spacer(modifier = Modifier.width(10.dp)) + + // Remove padding from the checkbox + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + Checkbox( + checked = expense.checked, + onCheckedChange = { checked -> + onExpenseCheckedChange(expense, checked) + }, + ) + } + } + } + + if (index < expenses.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(start = 70.dp), + color = colorResource(R.color.divider), + thickness = 1.dp, + ) + } else { + // Add inner padding for the FAB + Spacer(modifier = Modifier.height(80.dp)) + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + } + } + + + } + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/FABMenuOverlay.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/FABMenuOverlay.kt new file mode 100644 index 00000000..7bf2bc26 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/FABMenuOverlay.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.main.subviews + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@Composable +fun FABMenuOverlay( + onAddRecurringEntryPressed: () -> Unit, + onAddEntryPressed: () -> Unit, + onTapOutsideCTAs: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(color = colorResource(R.color.menu_background_overlay_color)) + .padding(bottom = 90.dp, end = 16.dp) + .clickable( + onClick = onTapOutsideCTAs, + indication = null, + interactionSource = null, + ), + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.End, + ) { + Row( + modifier = Modifier.clickable(onClick = onAddRecurringEntryPressed), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(color = Color.Black) + .padding(horizontal = 10.dp, vertical = 5.dp), + text = stringResource(R.string.fab_add_monthly_expense), + color = Color.White, + fontSize = 15.sp, + ) + + Spacer(modifier = Modifier.width(10.dp)) + + FloatingActionButton( + onClick = onAddRecurringEntryPressed, + containerColor = colorResource(R.color.fab_add_monthly_expense), + contentColor = colorResource(R.color.white), + ) { + Icon( + painter = painterResource(R.drawable.ic_autorenew_white), + contentDescription = stringResource(R.string.fab_add_monthly_expense), + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.clickable(onClick = onAddEntryPressed), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(color = Color.Black) + .padding(horizontal = 10.dp, vertical = 5.dp), + text = stringResource(R.string.fab_add_expense), + color = Color.White, + fontSize = 15.sp, + ) + + Spacer(modifier = Modifier.width(10.dp)) + + FloatingActionButton( + onClick = onAddEntryPressed, + containerColor = colorResource(R.color.fab_add_expense), + contentColor = colorResource(R.color.white), + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_add_24), + contentDescription = stringResource(R.string.fab_add_expense), + ) + } + } + + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/MainViewContent.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/MainViewContent.kt new file mode 100644 index 00000000..21ebbce0 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/MainViewContent.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.main.subviews + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.benoitletondor.easybudgetapp.compose.components.LoadingView +import com.benoitletondor.easybudgetapp.model.DataForMonth +import com.benoitletondor.easybudgetapp.model.Expense +import com.benoitletondor.easybudgetapp.view.main.MainViewModel +import com.benoitletondor.easybudgetapp.view.main.subviews.calendar.CalendarView +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import java.util.Currency + +@Composable +fun MainViewContent( + modifier: Modifier = Modifier, + selectedAccountFlow: StateFlow, + dbStateFlow: StateFlow, + hasPendingInvitationsFlow: StateFlow, + forceRefreshDataFlow: Flow, + firstDayOfWeekFlow: StateFlow, + includeCheckedBalanceFlow: StateFlow, + getDataForMonth: suspend (YearMonth) -> DataForMonth, + selectedDateFlow: StateFlow, + lowMoneyAmountWarningFlow: StateFlow, + goBackToCurrentMonthEventFlow: Flow, + dayDataFlow: StateFlow, + userCurrencyFlow: StateFlow, + showExpensesCheckBoxFlow: StateFlow, + appInitDate: LocalDate, + onCurrentAccountTapped: () -> Unit, + onMonthChanged: (YearMonth) -> Unit, + onDateClicked: (LocalDate) -> Unit, + onDateLongClicked: (LocalDate) -> Unit, + onRetryDBLoadingButtonPressed: () -> Unit, + onExpenseCheckedChange: (Expense, Boolean) -> Unit, + onExpensePressed: (Expense) -> Unit, + onExpenseLongPressed: (Expense) -> Unit, +) { + val account by selectedAccountFlow.collectAsState() + + Column( + modifier = modifier.fillMaxSize(), + ) { + when(val selectedAccount = account) { + MainViewModel.SelectedAccount.Loading -> LoadingView() + is MainViewModel.SelectedAccount.Selected -> { + SelectedAccountHeader( + selectedAccount = selectedAccount, + hasPendingInvitationsFlow = hasPendingInvitationsFlow, + onCurrentAccountTapped = onCurrentAccountTapped, + ) + + val dbState by dbStateFlow.collectAsState() + + when(val currentDbState = dbState) { + is MainViewModel.DBState.Error -> DBLoadingErrorView( + error = currentDbState.error, + onRetryButtonClicked = onRetryDBLoadingButtonPressed, + ) + is MainViewModel.DBState.Loaded -> { + CalendarView( + dbStateFlow = dbStateFlow, + appInitDate = appInitDate, + forceRefreshDataFlow = forceRefreshDataFlow, + firstDayOfWeekFlow = firstDayOfWeekFlow, + includeCheckedBalanceFlow = includeCheckedBalanceFlow, + getDataForMonth = getDataForMonth, + selectedDateFlow = selectedDateFlow, + lowMoneyAmountWarningFlow = lowMoneyAmountWarningFlow, + onMonthChanged = onMonthChanged, + goBackToCurrentMonthEventFlow = goBackToCurrentMonthEventFlow, + onDateSelected = onDateClicked, + onDateLongClicked = onDateLongClicked, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + ExpensesView( + dayDataFlow = dayDataFlow, + lowMoneyAmountWarningFlow = lowMoneyAmountWarningFlow, + userCurrencyFlow = userCurrencyFlow, + showExpensesCheckBoxFlow = showExpensesCheckBoxFlow, + onExpenseCheckedChange = onExpenseCheckedChange, + onExpensePressed = onExpensePressed, + onExpenseLongPressed = onExpenseLongPressed, + ) + } + MainViewModel.DBState.Loading, + MainViewModel.DBState.NotLoaded -> LoadingView() + } + } + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/MonthlyReportHint.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/MonthlyReportHint.kt new file mode 100644 index 00000000..c8924f36 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/MonthlyReportHint.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.main.subviews + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@Composable +fun MonthlyReportHint( + modifier: Modifier = Modifier, + onDismiss: () -> Unit, +) { + Column( + modifier = modifier.widthIn(max = 200.dp), + horizontalAlignment = Alignment.End, + ) { + Image( + modifier = Modifier + .size(50.dp) + .padding(end = 10.dp), + painter = painterResource(R.drawable.ic_baseline_arrow_drop_up_24), + contentDescription = null, + colorFilter = ColorFilter.tint(color = colorResource(R.color.monthly_report_hint_background)) + ) + + Column( + modifier = Modifier + .offset(y = (-5).dp) + .background(color = colorResource(R.color.monthly_report_hint_background)) + .padding(vertical = 5.dp, horizontal = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.monthly_report_hint), + color = Color.White, + fontSize = 15.sp, + ) + + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Text(text = stringResource(R.string.ok)) + } + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/SelectedAccountHeader.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/SelectedAccountHeader.kt new file mode 100644 index 00000000..a27f2678 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/SelectedAccountHeader.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.main.subviews + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.view.main.MainViewModel +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun SelectedAccountHeader( + selectedAccount: MainViewModel.SelectedAccount.Selected, + hasPendingInvitationsFlow: StateFlow, + onCurrentAccountTapped: () -> Unit, +) { + val hasPendingInvitations by hasPendingInvitationsFlow.collectAsState() + + Box( + modifier = Modifier + .fillMaxWidth() + .background(colorResource(R.color.status_bar_color)) + .padding(bottom = 8.dp) + .clickable(onClick = onCurrentAccountTapped) + .padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 10.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = Modifier.weight(1f), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.main_account_name) + " ", + fontWeight = FontWeight.SemiBold, + color = colorResource(R.color.action_bar_text_color), + ) + + Text( + modifier = Modifier.fillMaxWidth(), + text = when(selectedAccount) { + MainViewModel.SelectedAccount.Selected.Offline -> stringResource(R.string.main_account_default_name) + is MainViewModel.SelectedAccount.Selected.Online -> stringResource(R.string.main_account_online_name, selectedAccount.name) + }, + maxLines = 1, + color = colorResource(R.color.action_bar_text_color), + overflow = TextOverflow.Ellipsis, + ) + } + } + + if (hasPendingInvitations) { + Box( + modifier = Modifier.padding(start = 16.dp, end = 6.dp), + ){ + Image( + painter = painterResource(id = R.drawable.ic_baseline_notifications_24), + colorFilter = ColorFilter.tint(colorResource(R.color.action_bar_text_color)), + contentDescription = stringResource(R.string.account_pending_invitation_description), + ) + Box( + modifier = Modifier + .size(7.dp) + .clip(CircleShape) + .background(colorResource(R.color.budget_red)) + .align(Alignment.TopEnd) + ) + } + } else { + Image( + painter = painterResource(id = R.drawable.ic_baseline_arrow_drop_down_24), + colorFilter = ColorFilter.tint(colorResource(R.color.action_bar_text_color)), + contentDescription = null, + modifier = Modifier.padding(start = 10.dp), + ) + } + } + + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/TopBar.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/TopBar.kt new file mode 100644 index 00000000..6c13b6e9 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/TopBar.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.main.subviews + +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.compose.AppTopAppBar +import com.benoitletondor.easybudgetapp.compose.AppTopBarMoreMenuItem +import com.benoitletondor.easybudgetapp.compose.BackButtonBehavior +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun MainViewTopBar( + showActionButtonsFlow: StateFlow, + showPremiumRelatedButtonsFlow: StateFlow, + showManageAccountButtonFlow: StateFlow, + showGoBackToCurrentMonthButtonFlow: StateFlow, + onSettingsButtonPressed: () -> Unit, + onAdjustCurrentBalanceButtonPressed: () -> Unit, + onTickAllPastEntriesButtonPressed: () -> Unit, + onManageAccountButtonPressed: () -> Unit, + onDiscoverPremiumButtonPressed: () -> Unit, + onMonthlyReportButtonPressed: () -> Unit, + onGoBackToCurrentMonthButtonPressed: () -> Unit, +) { + AppTopAppBar( + title = stringResource(R.string.app_name), + backButtonBehavior = BackButtonBehavior.Hidden, + actions = { + val showActionButtons by showActionButtonsFlow.collectAsState() + val showPremiumRelatedButtons by showPremiumRelatedButtonsFlow.collectAsState() + val showManageAccountButton by showManageAccountButtonFlow.collectAsState() + val showGoBackToCurrentMonthButton by showGoBackToCurrentMonthButtonFlow.collectAsState() + + if (showActionButtons) { + if (showManageAccountButton) { + IconButton( + onClick = onManageAccountButtonPressed, + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_manage_accounts_24), + contentDescription = stringResource(R.string.action_manage_account), + ) + } + } + + if (showGoBackToCurrentMonthButton) { + IconButton( + onClick = onGoBackToCurrentMonthButtonPressed, + ) { + Icon( + painter = painterResource(R.drawable.ic_calendar_today), + contentDescription = stringResource(R.string.action_go_to_current_month), + ) + } + } + + if (showPremiumRelatedButtons) { + IconButton( + onClick = onMonthlyReportButtonPressed, + ) { + Icon( + painter = painterResource(R.drawable.ic_list_alt_24), + contentDescription = stringResource(R.string.monthly_report_button_title), + ) + } + } else { + IconButton( + onClick = onDiscoverPremiumButtonPressed, + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_star_24), + contentDescription = stringResource(R.string.action_become_premium), + ) + } + } + } + + AppTopBarMoreMenuItem { dismiss -> + if (showActionButtons) { + DropdownMenuItem( + onClick = { + onAdjustCurrentBalanceButtonPressed() + dismiss() + }, + text = { + Text( + text = stringResource(R.string.action_balance), + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + ) + }, + ) + } + + if (showActionButtons && showPremiumRelatedButtons) { + DropdownMenuItem( + onClick = { + onTickAllPastEntriesButtonPressed() + dismiss() + }, + text = { + Text( + text = stringResource(R.string.action_mark_all_past_entries_as_checked), + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + ) + }, + ) + } + + DropdownMenuItem( + onClick = { + onSettingsButtonPressed() + dismiss() + }, + text = { + Text( + text = stringResource(R.string.action_settings), + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + ) + }, + ) + } + } + ) +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/accountselector/view/Accounts.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/accountselector/AccountSelectorView.kt similarity index 88% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/accountselector/view/Accounts.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/accountselector/AccountSelectorView.kt index c55d4f92..49cc763c 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/accountselector/view/Accounts.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/accountselector/AccountSelectorView.kt @@ -14,8 +14,9 @@ * limitations under the License. */ -package com.benoitletondor.easybudgetapp.view.main.accountselector.view +package com.benoitletondor.easybudgetapp.view.main.subviews.accountselector +import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -38,6 +39,7 @@ import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -50,26 +52,40 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel import com.benoitletondor.easybudgetapp.R import com.benoitletondor.easybudgetapp.auth.CurrentUser -import com.benoitletondor.easybudgetapp.theme.AppTheme +import com.benoitletondor.easybudgetapp.compose.AppTheme +import com.benoitletondor.easybudgetapp.helper.launchCollect import com.benoitletondor.easybudgetapp.view.main.MainViewModel -import com.benoitletondor.easybudgetapp.view.main.accountselector.AccountSelectorViewModel import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow @Composable -fun AccountsView(viewModel: AccountSelectorViewModel) { +fun AccountSelectorView( + viewModel: AccountSelectorViewModel = hiltViewModel(), + onAccountSelected: (MainViewModel.SelectedAccount.Selected) -> Unit, + onOpenBecomeProScreen: () -> Unit, + onOpenLoginScreen: (shouldDismissAfterAuth: Boolean) -> Unit, + onOpenCreateAccountScreen: () -> Unit, +) { val state: AccountSelectorViewModel.State by viewModel.stateFlow.collectAsState() AccountsView( state = state, + eventFlow = viewModel.eventFlow, onIabErrorRetryButtonClicked = viewModel::onIabErrorRetryButtonClicked, onErrorRetryButtonClicked = viewModel::onRetryErrorButtonClicked, - onAccountSelected = viewModel::onAccountSelected, - onBecomeProButtonClicked = viewModel::onBecomeProButtonClicked, - onLoginButtonPressed = viewModel::onLoginButtonPressed, - onEmailTapped = viewModel::onEmailTapped, - onCreateAccountClicked = viewModel::onCreateAccountClicked, + onAccountSelected = onAccountSelected, + onBecomeProButtonClicked = onOpenBecomeProScreen, + onLoginButtonPressed = { + onOpenLoginScreen(true) + }, + onEmailTapped = { + onOpenLoginScreen(false) + }, + onCreateAccountClicked = onOpenCreateAccountScreen, onAcceptInvitationConfirmed = viewModel::onAcceptInvitationConfirmed, onRejectInvitationConfirmed = viewModel::onRejectInvitationConfirmed, ) @@ -78,6 +94,7 @@ fun AccountsView(viewModel: AccountSelectorViewModel) { @Composable private fun AccountsView( state: AccountSelectorViewModel.State, + eventFlow: Flow, onIabErrorRetryButtonClicked: () -> Unit, onErrorRetryButtonClicked: () -> Unit, onAccountSelected: (MainViewModel.SelectedAccount.Selected) -> Unit, @@ -99,11 +116,45 @@ private fun AccountsView( } val shouldDisplayOfflineBackupEnabled = state is AccountSelectorViewModel.OfflineBackStateAvailable && state.isOfflineBackupEnabled + val context = LocalContext.current + + LaunchedEffect(key1 = "eventsListener") { + launchCollect(eventFlow) { event -> + when(event) { + is AccountSelectorViewModel.Event.ErrorAcceptingInvitation -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.account_invitation_error_accepting_title) + .setMessage(context.getString(R.string.account_invitation_error_accepting_message, event.error.localizedMessage)) + .setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + } + .show() + } + is AccountSelectorViewModel.Event.ErrorRejectingInvitation -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.account_invitation_error_rejecting_title) + .setMessage(context.getString(R.string.account_invitation_error_rejecting_message, event.error.localizedMessage)) + .setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + } + .show() + } + AccountSelectorViewModel.Event.InvitationAccepted -> { + Toast.makeText(context, R.string.account_invitation_accepted_message, Toast.LENGTH_LONG).show() + } + AccountSelectorViewModel.Event.InvitationRejected -> { + Toast.makeText(context, R.string.account_invitation_rejected_message, Toast.LENGTH_LONG).show() + } + } + } + } + Column( modifier = Modifier .padding( - vertical = 20.dp, - horizontal = 16.dp + start = 16.dp, + end = 16.dp, + bottom = 46.dp, ) .verticalScroll(rememberScrollState()), ) { @@ -417,7 +468,7 @@ private fun AccountButton( Surface( modifier = Modifier .fillMaxWidth() - .clickable ( + .clickable( enabled = enabled, onClick = onClick, ), @@ -544,10 +595,11 @@ private fun InvitationView( @Composable @Preview(name = "Loading preview") -fun AccountsLoadingViewPreview() { +private fun AccountsLoadingViewPreview() { AppTheme { AccountsView( state = AccountSelectorViewModel.State.Loading, + eventFlow = MutableSharedFlow(), onIabErrorRetryButtonClicked = {}, onErrorRetryButtonClicked = {}, onAccountSelected = {}, @@ -567,6 +619,7 @@ fun AccountsIabErrorViewPreview() { AppTheme { AccountsView( state = AccountSelectorViewModel.State.IabError, + eventFlow = MutableSharedFlow(), onIabErrorRetryButtonClicked = {}, onErrorRetryButtonClicked = {}, onAccountSelected = {}, @@ -588,6 +641,7 @@ fun AccountsNotProViewPreview() { state = AccountSelectorViewModel.State.NotPro( isOfflineBackupEnabled = false, ), + eventFlow = MutableSharedFlow(), onIabErrorRetryButtonClicked = {}, onErrorRetryButtonClicked = {}, onAccountSelected = {}, @@ -609,6 +663,7 @@ fun AccountsNotAuthenticatedViewPreview() { state = AccountSelectorViewModel.State.NotAuthenticated( isOfflineBackupEnabled = false, ), + eventFlow = MutableSharedFlow(), onIabErrorRetryButtonClicked = {}, onErrorRetryButtonClicked = {}, onAccountSelected = {}, @@ -666,6 +721,7 @@ fun AccountsAvailableViewPreview() { pendingInvitations = listOf(), isOfflineBackupEnabled = true, ), + eventFlow = MutableSharedFlow(), onIabErrorRetryButtonClicked = {}, onErrorRetryButtonClicked = {}, onAccountSelected = {}, @@ -743,6 +799,7 @@ fun AccountsAvailableFullViewPreview() { ), isOfflineBackupEnabled = false, ), + eventFlow = MutableSharedFlow(), onIabErrorRetryButtonClicked = {}, onErrorRetryButtonClicked = {}, onAccountSelected = {}, diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/accountselector/AccountSelectorViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/accountselector/AccountSelectorViewModel.kt similarity index 82% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/accountselector/AccountSelectorViewModel.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/accountselector/AccountSelectorViewModel.kt index 5fb653bd..aafced45 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/accountselector/AccountSelectorViewModel.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/accountselector/AccountSelectorViewModel.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.benoitletondor.easybudgetapp.view.main.accountselector +package com.benoitletondor.easybudgetapp.view.main.subviews.accountselector import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -25,12 +25,12 @@ import com.benoitletondor.easybudgetapp.auth.AuthState import com.benoitletondor.easybudgetapp.auth.CurrentUser import com.benoitletondor.easybudgetapp.helper.Logger import com.benoitletondor.easybudgetapp.helper.MutableLiveFlow +import com.benoitletondor.easybudgetapp.helper.combine import com.benoitletondor.easybudgetapp.iab.Iab import com.benoitletondor.easybudgetapp.iab.PremiumCheckStatus import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.benoitletondor.easybudgetapp.parameters.getLatestSelectedOnlineAccountId -import com.benoitletondor.easybudgetapp.parameters.isBackupEnabled -import com.benoitletondor.easybudgetapp.view.main.MainViewModel +import com.benoitletondor.easybudgetapp.parameters.watchIsBackupEnabled +import com.benoitletondor.easybudgetapp.parameters.watchLatestSelectedOnlineAccountId import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow @@ -38,7 +38,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -52,7 +51,7 @@ class AccountSelectorViewModel @Inject constructor( private val iab: Iab, private val auth: Auth, private val accounts: Accounts, - private val parameters: Parameters, + parameters: Parameters, ) : ViewModel() { private val eventMutableFlow = MutableLiveFlow() val eventFlow: Flow = eventMutableFlow @@ -92,45 +91,47 @@ class AccountSelectorViewModel @Inject constructor( else -> flowOf(emptyList()) } }, - loadingInvitationMutableFlow - ) { iabStatus, authStatus, onlineAccounts, pendingAccountsInvitation, maybeLoadingInvitation -> + loadingInvitationMutableFlow, + parameters.watchLatestSelectedOnlineAccountId(), + parameters.watchIsBackupEnabled(), + ) { iabStatus, authStatus, onlineAccounts, pendingAccountsInvitation, maybeLoadingInvitation, maybeSelectedOnlineAccountId, isBackupEnabled -> return@combine when(iabStatus) { PremiumCheckStatus.INITIALIZING, PremiumCheckStatus.CHECKING -> State.Loading PremiumCheckStatus.ERROR -> State.IabError PremiumCheckStatus.NOT_PREMIUM -> State.NotPro(isOfflineBackupEnabled = false) PremiumCheckStatus.LEGACY_PREMIUM, - PremiumCheckStatus.PREMIUM_SUBSCRIBED -> State.NotPro(isOfflineBackupEnabled = parameters.isBackupEnabled()) + PremiumCheckStatus.PREMIUM_SUBSCRIBED -> State.NotPro(isOfflineBackupEnabled = isBackupEnabled) PremiumCheckStatus.PRO_SUBSCRIBED -> when(authStatus) { is AuthState.Authenticated -> { val ownAccounts = onlineAccounts .filter { it.isUserOwner } - .map { it.toViewModelAccount() } + .map { it.toViewModelAccount(maybeSelectedOnlineAccountId = maybeSelectedOnlineAccountId) } val invitedAccounts = onlineAccounts .filter { !it.isUserOwner } - .map { it.toViewModelAccount() } + .map { it.toViewModelAccount(maybeSelectedOnlineAccountId = maybeSelectedOnlineAccountId) } State.AccountsAvailable( userEmail = authStatus.currentUser.email, - isOfflineSelected = parameters.getLatestSelectedOnlineAccountId() == null || + isOfflineSelected = maybeSelectedOnlineAccountId == null || (ownAccounts.none { it.selected } && invitedAccounts.none { it.selected } ), ownAccounts = ownAccounts.take(5), showCreateOnlineAccountButton = ownAccounts.size < 5, invitedAccounts = invitedAccounts, pendingInvitations = pendingAccountsInvitation.map { account -> Invitation( - account = account.toViewModelAccount(), + account = account.toViewModelAccount(maybeSelectedOnlineAccountId = maybeSelectedOnlineAccountId), user = authStatus.currentUser, isLoading = maybeLoadingInvitation?.account?.id == account.id, ) }, - isOfflineBackupEnabled = parameters.isBackupEnabled(), + isOfflineBackupEnabled = isBackupEnabled, ) } AuthState.Authenticating -> State.Loading AuthState.NotAuthenticated -> State.NotAuthenticated( - isOfflineBackupEnabled = parameters.isBackupEnabled(), + isOfflineBackupEnabled = isBackupEnabled, ) } } @@ -155,36 +156,6 @@ class AccountSelectorViewModel @Inject constructor( } } - fun onAccountSelected(account: MainViewModel.SelectedAccount.Selected) { - viewModelScope.launch { - eventMutableFlow.emit(Event.AccountSelected(account)) - } - } - - fun onBecomeProButtonClicked() { - viewModelScope.launch { - eventMutableFlow.emit(Event.OpenProScreen) - } - } - - fun onLoginButtonPressed() { - viewModelScope.launch { - eventMutableFlow.emit(Event.OpenLoginScreen(shouldDismissAfterAuth = true)) - } - } - - fun onEmailTapped() { - viewModelScope.launch { - eventMutableFlow.emit(Event.OpenLoginScreen(shouldDismissAfterAuth = false)) - } - } - - fun onCreateAccountClicked() { - viewModelScope.launch { - eventMutableFlow.emit(Event.OpenCreateAccountScreen) - } - } - fun onAcceptInvitationConfirmed(invitation: Invitation) { viewModelScope.launch { if (loadingInvitationMutableFlow.value != null) { @@ -252,12 +223,12 @@ class AccountSelectorViewModel @Inject constructor( ) } - private fun com.benoitletondor.easybudgetapp.accounts.model.Account.toViewModelAccount() = Account( + private fun com.benoitletondor.easybudgetapp.accounts.model.Account.toViewModelAccount(maybeSelectedOnlineAccountId: String?) = Account( id = id, secret = secret, name = name, ownerEmail = ownerEmail, - selected = parameters.getLatestSelectedOnlineAccountId() == id, + selected = maybeSelectedOnlineAccountId == id, ) data class Invitation( @@ -288,13 +259,9 @@ class AccountSelectorViewModel @Inject constructor( } sealed class Event { - data class AccountSelected(val account: MainViewModel.SelectedAccount.Selected) : Event() class ErrorAcceptingInvitation(val error: Throwable) : Event() data object InvitationAccepted : Event() class ErrorRejectingInvitation(val error: Throwable) : Event() data object InvitationRejected : Event() - data object OpenProScreen : Event() - data class OpenLoginScreen(val shouldDismissAfterAuth: Boolean) : Event() - data object OpenCreateAccountScreen : Event() } } \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/CalendarView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/CalendarView.kt similarity index 71% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/CalendarView.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/CalendarView.kt index 5b1902e9..e6225738 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/CalendarView.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/CalendarView.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.benoitletondor.easybudgetapp.view.main.account.calendar +package com.benoitletondor.easybudgetapp.view.main.subviews.calendar import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -30,13 +30,9 @@ import androidx.compose.ui.Modifier import com.benoitletondor.easybudgetapp.helper.computeCalendarMinDateFromInitDate import com.benoitletondor.easybudgetapp.helper.launchCollect import com.benoitletondor.easybudgetapp.model.DataForMonth -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.benoitletondor.easybudgetapp.parameters.getInitDate -import com.benoitletondor.easybudgetapp.parameters.watchFirstDayOfWeek -import com.benoitletondor.easybudgetapp.parameters.watchLowMoneyWarningAmount -import com.benoitletondor.easybudgetapp.view.main.account.AccountViewModel -import com.benoitletondor.easybudgetapp.view.main.account.calendar.views.CalendarDatesView -import com.benoitletondor.easybudgetapp.view.main.account.calendar.views.CalendarHeaderView +import com.benoitletondor.easybudgetapp.view.main.MainViewModel +import com.benoitletondor.easybudgetapp.view.main.subviews.calendar.views.CalendarDatesView +import com.benoitletondor.easybudgetapp.view.main.subviews.calendar.views.CalendarHeaderView import com.kizitonwose.calendar.compose.rememberCalendarState import com.kizitonwose.calendar.core.yearMonth import kotlinx.coroutines.flow.Flow @@ -48,39 +44,7 @@ import java.time.YearMonth @Composable fun CalendarView( - parameters: Parameters, - dbAvailableFlow: StateFlow, - forceRefreshDataFlow: Flow, - includeCheckedBalanceFlow: StateFlow, - selectedDateFlow: StateFlow, - onMonthChanged: (YearMonth) -> Unit, - goBackToCurrentMonthEventFlow: Flow, - onDateSelected: (LocalDate) -> Unit, - onDateLongClicked: (LocalDate) -> Unit, -) { - val dbState by dbAvailableFlow.collectAsState() - - when(val currentDbState = dbState) { - is AccountViewModel.DBState.Error, - AccountViewModel.DBState.Loading -> Unit - is AccountViewModel.DBState.Loaded -> CalendarView( - appInitDate = parameters.getInitDate() ?: LocalDate.now(), - forceRefreshDataFlow = forceRefreshDataFlow, - firstDayOfWeekFlow = parameters.watchFirstDayOfWeek(), - includeCheckedBalanceFlow = includeCheckedBalanceFlow, - getDataForMonth = currentDbState.db::getDataForMonth, - selectedDateFlow = selectedDateFlow, - lowMoneyAmountWarningFlow = parameters.watchLowMoneyWarningAmount(), - onMonthChanged = onMonthChanged, - goBackToCurrentMonthEventFlow = goBackToCurrentMonthEventFlow, - onDateSelected = onDateSelected, - onDateLongClicked = onDateLongClicked, - ) - } -} - -@Composable -private fun CalendarView( + dbStateFlow: StateFlow, appInitDate: LocalDate, forceRefreshDataFlow: Flow, firstDayOfWeekFlow: StateFlow, @@ -152,6 +116,7 @@ private fun CalendarView( val includeCheckedBalance by includeCheckedBalanceFlow.collectAsState() CalendarDatesView( + dbStateFlow = dbStateFlow, calendarState = calendarState, forceRefreshDataFlow = forceRefreshDataFlow, getDataForMonth = getDataForMonth, diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/NumberFormatter.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/NumberFormatter.kt similarity index 96% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/NumberFormatter.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/NumberFormatter.kt index bfc98fda..850f76d1 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/NumberFormatter.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/NumberFormatter.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.benoitletondor.easybudgetapp.view.main.account.calendar +package com.benoitletondor.easybudgetapp.view.main.subviews.calendar import android.icu.number.Notation import android.icu.text.CompactDecimalFormat diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/views/CalendarDatesView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/views/CalendarDatesView.kt similarity index 96% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/views/CalendarDatesView.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/views/CalendarDatesView.kt index 159b6ee9..bb46c7bd 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/views/CalendarDatesView.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/views/CalendarDatesView.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.benoitletondor.easybudgetapp.view.main.account.calendar.views +package com.benoitletondor.easybudgetapp.view.main.subviews.calendar.views import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -50,6 +50,7 @@ import com.benoitletondor.easybudgetapp.R import com.benoitletondor.easybudgetapp.helper.Logger import com.benoitletondor.easybudgetapp.helper.launchCollect import com.benoitletondor.easybudgetapp.model.DataForMonth +import com.benoitletondor.easybudgetapp.view.main.MainViewModel import com.kizitonwose.calendar.compose.CalendarState import com.kizitonwose.calendar.compose.HorizontalCalendar import com.kizitonwose.calendar.core.CalendarMonth @@ -58,8 +59,8 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.time.LocalDate import java.time.YearMonth import java.time.format.TextStyle @@ -67,6 +68,7 @@ import java.util.Locale @Composable fun CalendarDatesView( + dbStateFlow: StateFlow, calendarState: CalendarState, forceRefreshDataFlow: Flow, getDataForMonth: suspend (YearMonth) -> DataForMonth, @@ -92,8 +94,9 @@ fun CalendarDatesView( val (state, setState) = remember { mutableStateOf(State.NotAvailable) } val coroutineScope = rememberCoroutineScope() - LaunchedEffect("InitialLoading") { - withContext(Dispatchers.IO) { + // Loads on first load and when DB state changes + LaunchedEffect("DBAvailableLoading") { + launchCollect(dbStateFlow.filterIsInstance(), Dispatchers.IO) { loadData( setState = setState, calendarMonth = calendarMonth, diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/views/CalendarDayView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/views/CalendarDayView.kt similarity index 97% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/views/CalendarDayView.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/views/CalendarDayView.kt index eb396401..dc1e87f8 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/views/CalendarDayView.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/views/CalendarDayView.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.benoitletondor.easybudgetapp.view.main.account.calendar.views +package com.benoitletondor.easybudgetapp.view.main.subviews.calendar.views import android.content.res.Configuration import androidx.annotation.ColorRes @@ -54,9 +54,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.theme.AppTheme -import com.benoitletondor.easybudgetapp.view.main.account.calendar.NumberFormatter -import com.benoitletondor.easybudgetapp.view.main.account.calendar.RoundedToIntNumberFormatter +import com.benoitletondor.easybudgetapp.compose.AppTheme +import com.benoitletondor.easybudgetapp.view.main.subviews.calendar.NumberFormatter +import com.benoitletondor.easybudgetapp.view.main.subviews.calendar.RoundedToIntNumberFormatter @Composable fun BoxScope.InCalendarWithBalanceDayView( diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/views/CalendarHeaderView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/views/CalendarHeaderView.kt similarity index 97% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/views/CalendarHeaderView.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/views/CalendarHeaderView.kt index 3bf0419f..38313806 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/account/calendar/views/CalendarHeaderView.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/subviews/calendar/views/CalendarHeaderView.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.benoitletondor.easybudgetapp.view.main.account.calendar.views +package com.benoitletondor.easybudgetapp.view.main.subviews.calendar.views import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/manageaccount/ManageAccountView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/manageaccount/ManageAccountView.kt new file mode 100644 index 00000000..83ce02d9 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/manageaccount/ManageAccountView.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.manageaccount + +import android.widget.Toast +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.accounts.model.Invitation +import com.benoitletondor.easybudgetapp.compose.AppWithTopAppBarScaffold +import com.benoitletondor.easybudgetapp.compose.BackButtonBehavior +import com.benoitletondor.easybudgetapp.helper.SerializedSelectedOnlineAccount +import com.benoitletondor.easybudgetapp.helper.launchCollect +import com.benoitletondor.easybudgetapp.view.manageaccount.subviews.ContentView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.Serializable + +@Serializable +data class ManageAccountDestination(val selectedAccount: SerializedSelectedOnlineAccount) + +@Composable +fun ManageAccountView( + viewModel: ManageAccountViewModel, + navigateUp: () -> Unit, + finish: () -> Unit, +) { + ManageAccountView( + navigateUp = navigateUp, + stateFlow = viewModel.stateFlow, + eventFlow = viewModel.eventFlow, + onUpdateAccountNameClicked = viewModel::onUpdateAccountNameClicked, + onInvitationDeleteConfirmed = viewModel::onInvitationDeleteConfirmed, + onRetryButtonClicked = viewModel::onRetryButtonClicked, + onLeaveAccountConfirmed = viewModel::onLeaveAccountConfirmed, + onInviteEmailToAccount = viewModel::onInviteEmailToAccount, + onDeleteAccountConfirmed = viewModel::onDeleteAccountConfirmed, + finish = finish, + ) +} + +@Composable +private fun ManageAccountView( + navigateUp: () -> Unit, + stateFlow: StateFlow, + eventFlow: Flow, + onUpdateAccountNameClicked: (String) -> Unit, + onInvitationDeleteConfirmed: (Invitation) -> Unit, + onRetryButtonClicked: () -> Unit, + onLeaveAccountConfirmed: () -> Unit, + onInviteEmailToAccount: (String) -> Unit, + onDeleteAccountConfirmed: () -> Unit, + finish: () -> Unit, +) { + val context = LocalContext.current + + LaunchedEffect(key1 = "eventsListener") { + launchCollect(eventFlow) { event -> + when(event) { + ManageAccountViewModel.Event.AccountLeft -> Toast.makeText(context, R.string.account_management_account_left_confirmation, Toast.LENGTH_LONG).show() + ManageAccountViewModel.Event.AccountNameUpdated -> Toast.makeText(context, R.string.account_management_account_name_updated_confirmation, Toast.LENGTH_LONG).show() + is ManageAccountViewModel.Event.ErrorDeletingInvitation -> MaterialAlertDialogBuilder(context) + .setTitle(R.string.account_management_error_title) + .setMessage(context.getString(R.string.account_management_error_deleting_invitation, event.error.localizedMessage)) + .setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + } + .show() + is ManageAccountViewModel.Event.ErrorUpdatingAccountName -> MaterialAlertDialogBuilder(context) + .setTitle(R.string.account_management_error_title) + .setMessage(context.getString(R.string.account_management_error_updating_name, event.error.localizedMessage)) + .setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + } + .show() + is ManageAccountViewModel.Event.ErrorWhileInviting -> MaterialAlertDialogBuilder(context) + .setTitle(R.string.account_management_error_title) + .setMessage(context.getString(R.string.account_management_error_sending_invitation, event.error.localizedMessage)) + .setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + } + .show() + is ManageAccountViewModel.Event.ErrorWhileLeavingAccount -> MaterialAlertDialogBuilder(context) + .setTitle(R.string.account_management_error_title) + .setMessage(context.getString(R.string.account_management_error_leaving_account, event.error.localizedMessage)) + .setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + } + .show() + ManageAccountViewModel.Event.Finish -> finish() + is ManageAccountViewModel.Event.InvitationDeleted -> Toast.makeText(context, R.string.account_management_invitation_revoked, Toast.LENGTH_LONG).show() + is ManageAccountViewModel.Event.InvitationSent -> Toast.makeText(context, R.string.account_management_invitation_sent, Toast.LENGTH_LONG).show() + ManageAccountViewModel.Event.AccountDeleted -> Toast.makeText(context, R.string.account_management_account_deleted, Toast.LENGTH_LONG).show() + is ManageAccountViewModel.Event.ErrorWhileDeletingAccount -> MaterialAlertDialogBuilder(context) + .setTitle(R.string.account_management_error_title) + .setMessage(context.getString(R.string.account_management_error_deleting_account, event.error.localizedMessage)) + .setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + } + .show() + } + } + } + + AppWithTopAppBarScaffold( + title = stringResource(R.string.title_activity_manage_account), + backButtonBehavior = BackButtonBehavior.NavigateBack( + onBackButtonPressed = navigateUp, + ), + content = { contentPadding -> + val state by stateFlow.collectAsState() + + ContentView( + modifier = Modifier.padding(contentPadding), + state = state, + onUpdateAccountNameClicked = onUpdateAccountNameClicked, + onInvitationDeleteConfirmed = onInvitationDeleteConfirmed, + onRetryButtonClicked = onRetryButtonClicked, + onLeaveAccountConfirmed = onLeaveAccountConfirmed, + onInviteEmailToAccount = onInviteEmailToAccount, + onDeleteAccountConfirmed = onDeleteAccountConfirmed, + ) + } + ) +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/manageaccount/ManageAccountViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/manageaccount/ManageAccountViewModel.kt similarity index 94% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/manageaccount/ManageAccountViewModel.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/manageaccount/ManageAccountViewModel.kt index dde2dbaf..b499ef41 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/manageaccount/ManageAccountViewModel.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/manageaccount/ManageAccountViewModel.kt @@ -14,11 +14,9 @@ * limitations under the License. */ -package com.benoitletondor.easybudgetapp.view.main.manageaccount +package com.benoitletondor.easybudgetapp.view.manageaccount import android.util.Patterns -import androidx.compose.ui.text.toLowerCase -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.benoitletondor.easybudgetapp.accounts.Accounts @@ -31,9 +29,11 @@ import com.benoitletondor.easybudgetapp.db.onlineimpl.OnlineDB import com.benoitletondor.easybudgetapp.helper.Logger import com.benoitletondor.easybudgetapp.helper.MutableLiveFlow import com.benoitletondor.easybudgetapp.helper.combine +import com.benoitletondor.easybudgetapp.injection.CurrentDBProvider import com.benoitletondor.easybudgetapp.view.main.MainViewModel -import com.benoitletondor.easybudgetapp.view.main.account.AccountViewModel -import com.benoitletondor.easybudgetapp.view.main.manageaccount.ManageAccountActivity.Companion.SELECTED_ACCOUNT_EXTRA +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow @@ -48,17 +48,14 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject -@HiltViewModel -class ManageAccountViewModel @Inject constructor( +@HiltViewModel(assistedFactory = ManageAccountViewModelFactory::class) +class ManageAccountViewModel @AssistedInject constructor( auth: Auth, private val accounts: Accounts, - savedStateHandle: SavedStateHandle, + private val currentDBProvider: CurrentDBProvider, + @Assisted private val selectedAccount: MainViewModel.SelectedAccount.Selected.Online, ) : ViewModel() { - private val selectedAccount = savedStateHandle.get(SELECTED_ACCOUNT_EXTRA) - ?: throw IllegalStateException("Missing SELECTED_ACCOUNT_EXTRA arg") - private val isUpdatingNameMutableFlow = MutableStateFlow(false) private val isDeletingInvitationMutableFlow = MutableStateFlow(false) private val isSendingInvitationMutableFlow = MutableStateFlow(false) @@ -326,7 +323,7 @@ class ManageAccountViewModel @Inject constructor( try { val credentials = selectedAccount.toAccountCredentials() - val onlineDB = (AccountViewModel.getCurrentDB() as? OnlineDB) + val onlineDB = (currentDBProvider.activeDB as? OnlineDB) ?: throw IllegalStateException("No online DB found") if (onlineDB.account.id != credentials.id || onlineDB.account.secret != credentials.secret) { @@ -393,4 +390,9 @@ class ManageAccountViewModel @Inject constructor( data object AccountDeleted : Event() data class ErrorWhileDeletingAccount(val error: Exception) : Event() } -} \ No newline at end of file +} + +@AssistedFactory +interface ManageAccountViewModelFactory { + fun create(selectedAccount: MainViewModel.SelectedAccount.Selected.Online): ManageAccountViewModel +} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/manageaccount/view/Views.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/manageaccount/subviews/Views.kt similarity index 91% rename from Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/manageaccount/view/Views.kt rename to Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/manageaccount/subviews/Views.kt index 449acfd5..6dd2cdd1 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/main/manageaccount/view/Views.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/manageaccount/subviews/Views.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.benoitletondor.easybudgetapp.view.main.manageaccount.view +package com.benoitletondor.easybudgetapp.view.manageaccount.subviews import android.util.Patterns import android.view.WindowManager @@ -22,6 +22,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -71,13 +72,22 @@ import com.benoitletondor.easybudgetapp.R import com.benoitletondor.easybudgetapp.accounts.model.Invitation import com.benoitletondor.easybudgetapp.accounts.model.InvitationStatus import com.benoitletondor.easybudgetapp.auth.CurrentUser -import com.benoitletondor.easybudgetapp.theme.AppTheme -import com.benoitletondor.easybudgetapp.view.main.manageaccount.LoadingKind -import com.benoitletondor.easybudgetapp.view.main.manageaccount.ManageAccountViewModel +import com.benoitletondor.easybudgetapp.compose.AppTheme +import com.benoitletondor.easybudgetapp.view.manageaccount.ManageAccountViewModel import com.google.android.material.dialog.MaterialAlertDialogBuilder +private enum class LoadingKind { + LOADING_DATA, + DELETING_INVITATION, + SENDING_INVITATION, + UPDATING_NAME, + DELETING_ACCOUNT, + LEAVING_ACCOUNT, +} + @Composable fun ContentView( + modifier: Modifier, state: ManageAccountViewModel.State, onUpdateAccountNameClicked: (String) -> Unit, onInvitationDeleteConfirmed: (Invitation) -> Unit, @@ -86,30 +96,34 @@ fun ContentView( onInviteEmailToAccount: (String) -> Unit, onDeleteAccountConfirmed: () -> Unit, ) { - when(state) { - ManageAccountViewModel.State.DeletingInvitation -> LoadingView(kind = LoadingKind.DELETING_INVITATION) - is ManageAccountViewModel.State.Error -> ErrorView( - error = state.error, - onRetryButtonClicked = onRetryButtonClicked, - ) - ManageAccountViewModel.State.Loading -> LoadingView(kind = LoadingKind.LOADING_DATA) - is ManageAccountViewModel.State.Ready.Invited -> ManageAccountAsInvitedView( - accountName = state.accountName, - onLeaveAccountConfirmed = onLeaveAccountConfirmed, - ) - is ManageAccountViewModel.State.Ready.Owner -> ManageAccountAsOwnerView( - initialNameValue = state.accountName, - invitationsSent = state.invitationsSent, - invitationsAccepted = state.invitationsAccepted, - onUpdateAccountNameClicked = onUpdateAccountNameClicked, - onInvitationDeleteConfirmed = onInvitationDeleteConfirmed, - onInviteEmailToAccount = onInviteEmailToAccount, - onDeleteAccountConfirmed = onDeleteAccountConfirmed, - ) - ManageAccountViewModel.State.SendingInvitation -> LoadingView(kind = LoadingKind.SENDING_INVITATION) - ManageAccountViewModel.State.Updating -> LoadingView(kind = LoadingKind.UPDATING_NAME) - ManageAccountViewModel.State.DeletingAccount -> LoadingView(kind = LoadingKind.DELETING_ACCOUNT) - ManageAccountViewModel.State.LeavingAccount -> LoadingView(kind = LoadingKind.LEAVING_ACCOUNT) + Box( + modifier = modifier + ) { + when(state) { + ManageAccountViewModel.State.DeletingInvitation -> LoadingView(kind = LoadingKind.DELETING_INVITATION) + is ManageAccountViewModel.State.Error -> ErrorView( + error = state.error, + onRetryButtonClicked = onRetryButtonClicked, + ) + ManageAccountViewModel.State.Loading -> LoadingView(kind = LoadingKind.LOADING_DATA) + is ManageAccountViewModel.State.Ready.Invited -> ManageAccountAsInvitedView( + accountName = state.accountName, + onLeaveAccountConfirmed = onLeaveAccountConfirmed, + ) + is ManageAccountViewModel.State.Ready.Owner -> ManageAccountAsOwnerView( + initialNameValue = state.accountName, + invitationsSent = state.invitationsSent, + invitationsAccepted = state.invitationsAccepted, + onUpdateAccountNameClicked = onUpdateAccountNameClicked, + onInvitationDeleteConfirmed = onInvitationDeleteConfirmed, + onInviteEmailToAccount = onInviteEmailToAccount, + onDeleteAccountConfirmed = onDeleteAccountConfirmed, + ) + ManageAccountViewModel.State.SendingInvitation -> LoadingView(kind = LoadingKind.SENDING_INVITATION) + ManageAccountViewModel.State.Updating -> LoadingView(kind = LoadingKind.UPDATING_NAME) + ManageAccountViewModel.State.DeletingAccount -> LoadingView(kind = LoadingKind.DELETING_ACCOUNT) + ManageAccountViewModel.State.LeavingAccount -> LoadingView(kind = LoadingKind.LEAVING_ACCOUNT) + } } } @@ -533,6 +547,7 @@ private fun LoadingView( private fun LoadingStatePreview() { AppTheme { ContentView( + modifier = Modifier, state = ManageAccountViewModel.State.Loading, onUpdateAccountNameClicked = {}, onInvitationDeleteConfirmed = {}, @@ -549,6 +564,7 @@ private fun LoadingStatePreview() { private fun OwnerStateEmptyInvitationsPreview() { AppTheme { ContentView( + modifier = Modifier, state = ManageAccountViewModel.State.Ready.Owner( "accountName", CurrentUser("", "", ""), @@ -570,6 +586,7 @@ private fun OwnerStateEmptyInvitationsPreview() { private fun OwnerStateFullInvitationsPreview() { AppTheme { ContentView( + modifier = Modifier, state = ManageAccountViewModel.State.Ready.Owner( "accountName", CurrentUser("", "", ""), diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/MonthlyReportView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/MonthlyReportView.kt new file mode 100644 index 00000000..2a0142c0 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/MonthlyReportView.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.monthlyreport + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.compose.AppWithTopAppBarScaffold +import com.benoitletondor.easybudgetapp.compose.BackButtonBehavior +import com.benoitletondor.easybudgetapp.compose.components.LoadingView +import com.benoitletondor.easybudgetapp.helper.launchCollect +import com.benoitletondor.easybudgetapp.view.monthlyreport.subviews.EmptyView +import com.benoitletondor.easybudgetapp.view.monthlyreport.subviews.EntriesView +import com.benoitletondor.easybudgetapp.view.monthlyreport.subviews.ErrorView +import com.benoitletondor.easybudgetapp.view.monthlyreport.subviews.MonthsHeader +import com.benoitletondor.easybudgetapp.view.monthlyreport.subviews.RecapView +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.Serializable +import java.time.YearMonth +import java.util.Currency + +@Serializable +data class MonthlyReportDestination(val fromNotification: Boolean) + +@Composable +fun MonthlyReportView( + viewModel: MonthlyReportViewModel, + navigateUp: () -> Unit, + navigateToExportToCsv: (YearMonth) -> Unit, +) { + MonthlyReportView( + navigateUp = navigateUp, + shouldShowExportToCsvButtonFlow = viewModel.shouldShowExportToCsvButtonFlow, + eventFlow = viewModel.eventFlow, + stateFlow = viewModel.stateFlow, + monthDataStateFlow = viewModel.monthDataStateFlow, + userCurrencyStateFlow = viewModel.userCurrencyStateFlow, + onExportToCsvButtonPressed = viewModel::onExportToCsvButtonPressed, + onPreviousMonthClicked = viewModel::onPreviousMonthClicked, + onNextMonthClicked = viewModel::onNextMonthClicked, + onRetryLoadingMonthDataPressed = viewModel::onRetryLoadingMonthDataPressed, + navigateToExportToCsv = navigateToExportToCsv, + ) +} + +@Composable +private fun MonthlyReportView( + navigateUp: () -> Unit, + shouldShowExportToCsvButtonFlow: StateFlow, + eventFlow: Flow, + stateFlow: StateFlow, + monthDataStateFlow: StateFlow, + userCurrencyStateFlow: StateFlow, + onExportToCsvButtonPressed: () -> Unit, + onPreviousMonthClicked: () -> Unit, + onNextMonthClicked: () -> Unit, + onRetryLoadingMonthDataPressed: () -> Unit, + navigateToExportToCsv: (YearMonth) -> Unit, +) { + LaunchedEffect("eventsListener") { + launchCollect(eventFlow) { event -> + when(event) { + is MonthlyReportViewModel.Event.OpenExportToCsvScreen -> navigateToExportToCsv(event.month) + } + } + } + + AppWithTopAppBarScaffold( + title = stringResource(R.string.title_activity_monthly_report), + backButtonBehavior = BackButtonBehavior.NavigateBack( + onBackButtonPressed = navigateUp, + ), + actions = { + val shouldShowExportToCsvButton by shouldShowExportToCsvButtonFlow.collectAsState() + if (shouldShowExportToCsvButton) { + IconButton( + onClick = onExportToCsvButtonPressed, + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_upload_file_24), + contentDescription = stringResource(R.string.action_export), + ) + } + } + }, + content = { contentPaddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding( + top = contentPaddingValues.calculateTopPadding(), + start = contentPaddingValues.calculateStartPadding(LocalLayoutDirection.current), + end = contentPaddingValues.calculateEndPadding(LocalLayoutDirection.current), + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val state by stateFlow.collectAsState() + when(val currentState = state) { + MonthlyReportViewModel.State.Loading -> LoadingView() + is MonthlyReportViewModel.State.Loaded -> { + MonthsHeader( + selectedPosition = currentState.selectedPosition, + onPreviousMonthClicked = onPreviousMonthClicked, + onNextMonthClicked = onNextMonthClicked, + ) + + MonthData( + monthDataStateFlow = monthDataStateFlow, + userCurrencyStateFlow = userCurrencyStateFlow, + onRetryButtonClicked = onRetryLoadingMonthDataPressed, + ) + } + } + } + } + ) +} + + + +@Composable +private fun ColumnScope.MonthData( + monthDataStateFlow: StateFlow, + userCurrencyStateFlow: StateFlow, + onRetryButtonClicked: () -> Unit, +) { + val monthDataState by monthDataStateFlow.collectAsState() + when(val state = monthDataState) { + MonthlyReportViewModel.MonthDataState.Loading -> LoadingView() + is MonthlyReportViewModel.MonthDataState.Error -> ErrorView( + error = state.error, + onRetryButtonClicked = onRetryButtonClicked, + ) + MonthlyReportViewModel.MonthDataState.Empty -> { + RecapView( + userCurrencyStateFlow = userCurrencyStateFlow, + expensesAmount = 0.0, + revenuesAmount = 0.0, + ) + + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = colorResource(R.color.divider), + thickness = 1.dp, + ) + + EmptyView() + } + is MonthlyReportViewModel.MonthDataState.Loaded -> { + RecapView( + userCurrencyStateFlow = userCurrencyStateFlow, + expensesAmount = state.expensesAmount, + revenuesAmount = state.revenuesAmount, + ) + + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = colorResource(R.color.divider), + thickness = 1.dp, + ) + + EntriesView( + userCurrencyStateFlow = userCurrencyStateFlow, + expenses = state.expenses, + revenues = state.revenues, + ) + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/MonthlyReportViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/MonthlyReportViewModel.kt new file mode 100644 index 00000000..853e7667 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/MonthlyReportViewModel.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.monthlyreport + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.benoitletondor.easybudgetapp.helper.Logger +import com.benoitletondor.easybudgetapp.helper.MutableLiveFlow +import com.benoitletondor.easybudgetapp.helper.getListOfMonthsAvailableForUser +import com.benoitletondor.easybudgetapp.helper.watchUserCurrency +import com.benoitletondor.easybudgetapp.iab.Iab +import com.benoitletondor.easybudgetapp.iab.PremiumCheckStatus +import com.benoitletondor.easybudgetapp.injection.CurrentDBProvider +import com.benoitletondor.easybudgetapp.injection.requireDB +import com.benoitletondor.easybudgetapp.model.Expense +import com.benoitletondor.easybudgetapp.parameters.Parameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.YearMonth + +@HiltViewModel(assistedFactory = MonthlyReportViewModelFactory::class) +class MonthlyReportViewModel @AssistedInject constructor( + iab: Iab, + private val parameters: Parameters, + private val currentDBProvider: CurrentDBProvider, + @Assisted fromNotification: Boolean, +) : ViewModel() { + + val shouldShowExportToCsvButtonFlow: StateFlow = iab.iabStatusFlow + .map { it == PremiumCheckStatus.PRO_SUBSCRIBED } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + private val userMonthPositionShiftMutableFlow = MutableStateFlow(if (fromNotification) -1 else 0) + + val userCurrencyStateFlow get() = parameters.watchUserCurrency() + + val stateFlow: StateFlow = flow { + val months = withContext(Dispatchers.IO) { + parameters.getListOfMonthsAvailableForUser() + } + emit(months) + } + .flatMapLatest { months -> + var currentMonthPosition = months.indexOf(YearMonth.now()) + if (currentMonthPosition == -1) { + Logger.error("Error while getting current month position, returned -1", IllegalStateException("Current month not found in list of available months")) + currentMonthPosition = months.size - 1 + } + + return@flatMapLatest userMonthPositionShiftMutableFlow + .map { userMonthPositionShift -> + val selectedPosition = MonthlyReportSelectedPosition( + position = currentMonthPosition + userMonthPositionShift, + month = months[currentMonthPosition + userMonthPositionShift], + first = currentMonthPosition + userMonthPositionShift == 0, + last = currentMonthPosition + userMonthPositionShift >= months.size - 1, + ) + + return@map State.Loaded(months, selectedPosition) + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) + + private val retryLoadingMonthDataMutableFlow = MutableSharedFlow() + + val monthDataStateFlow: StateFlow = stateFlow + .filterIsInstance() + .map { state -> + val expensesForMonth = withContext(Dispatchers.Default) { + currentDBProvider.requireDB.getExpensesForMonth(state.selectedPosition.month) + } + + if( expensesForMonth.isEmpty() ) { + return@map MonthDataState.Empty + } + + val expenses = mutableListOf() + val revenues = mutableListOf() + var revenuesAmount = 0.0 + var expensesAmount = 0.0 + + withContext(Dispatchers.Default) { + for(expense in expensesForMonth) { + if( expense.isRevenue() ) { + revenues.add(expense) + revenuesAmount -= expense.amount + } else { + expenses.add(expense) + expensesAmount += expense.amount + } + } + } + + return@map MonthDataState.Loaded(expenses, revenues, expensesAmount, revenuesAmount) + } + .retryWhen { cause, _ -> + Logger.error("Error while loading month data", cause) + emit(MonthDataState.Error(cause)) + + retryLoadingMonthDataMutableFlow.first() + + emit(MonthDataState.Loading) + true + } + .stateIn(viewModelScope, SharingStarted.Eagerly, MonthDataState.Loading) + + private val eventMutableFlow = MutableLiveFlow() + val eventFlow: Flow = eventMutableFlow + + fun onExportToCsvButtonPressed() { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenExportToCsvScreen((stateFlow.value as State.Loaded).selectedPosition.month)) + } + } + + fun onRetryLoadingMonthDataPressed() { + viewModelScope.launch { + retryLoadingMonthDataMutableFlow.emit(Unit) + } + } + + fun onPreviousMonthClicked() { + val currentState = stateFlow.value + if (currentState is State.Loaded && currentState.selectedPosition.first) { + return + } + + userMonthPositionShiftMutableFlow.value-- + } + + fun onNextMonthClicked() { + val currentState = stateFlow.value + if (currentState is State.Loaded && currentState.selectedPosition.last) { + return + } + + userMonthPositionShiftMutableFlow.value++ + } + + sealed class Event { + data class OpenExportToCsvScreen(val month: YearMonth) : Event() + } + + sealed class State { + data object Loading : State() + @Immutable + data class Loaded(val months: List, val selectedPosition: MonthlyReportSelectedPosition) : State() + } + + sealed class MonthDataState { + data object Loading : MonthDataState() + data object Empty: MonthDataState() + @Immutable + data class Error(val error: Throwable) : MonthDataState() + @Immutable + data class Loaded(val expenses: List, val revenues: List, val expensesAmount: Double, val revenuesAmount: Double) : MonthDataState() + } +} + +@AssistedFactory +interface MonthlyReportViewModelFactory { + fun create(fromNotification: Boolean): MonthlyReportViewModel +} + +data class MonthlyReportSelectedPosition( + val position: Int, + val month: YearMonth, + val first: Boolean, + val last: Boolean, +) \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/export/MonthlyReportExportView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/export/MonthlyReportExportView.kt new file mode 100644 index 00000000..078422a8 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/export/MonthlyReportExportView.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.monthlyreport.export + +import android.app.Activity +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.content.FileProvider +import com.benoitletondor.easybudgetapp.BuildConfig +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.compose.AppWithTopAppBarScaffold +import com.benoitletondor.easybudgetapp.compose.BackButtonBehavior +import com.benoitletondor.easybudgetapp.helper.SerializedYearMonth +import com.benoitletondor.easybudgetapp.helper.launchCollect +import com.benoitletondor.easybudgetapp.view.monthlyreport.export.subviews.ContentView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.Serializable + +@Serializable +data class MonthlyReportExportDestination(val month: SerializedYearMonth) + +@Composable +fun MonthlyReportExportView( + viewModel: MonthlyReportExportViewModel, + navigateUp: () -> Unit, + finish: () -> Unit, +) { + MonthlyReportExportView( + stateFlow = viewModel.stateFlow, + eventFlow = viewModel.eventFlow, + navigateUp = navigateUp, + onRetryButtonClicked = viewModel::onRetryButtonClicked, + onDownloadButtonClicked = viewModel::onDownloadButtonClicked, + onErrorOpeningShareCsv = viewModel::onErrorOpeningShareCsv, + onShareCsvFinished = viewModel::onShareCsvFinished, + finish = finish, + ) +} + +@Composable +private fun MonthlyReportExportView( + stateFlow: StateFlow, + eventFlow: Flow, + navigateUp: () -> Unit, + onRetryButtonClicked: () -> Unit, + onDownloadButtonClicked: () -> Unit, + onErrorOpeningShareCsv: (Exception) -> Unit, + onShareCsvFinished: (success: Boolean) -> Unit, + finish: () -> Unit, +) { + val context = LocalContext.current + + val shareCsvLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + onShareCsvFinished(result.resultCode == Activity.RESULT_OK) + } + + LaunchedEffect(key1 = "eventsListener") { + launchCollect(eventFlow) { event -> + when(event) { + is MonthlyReportExportViewModel.Event.ShowShareCsv -> { + try { + val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", event.csvFile) + + val shareIntent = Intent().apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // Allow external app to open the file + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, uri) + type = "text/csv" + } + + shareCsvLauncher.launch(Intent.createChooser(shareIntent, null)) + } catch (e: Exception) { + onErrorOpeningShareCsv(e) + } + } + is MonthlyReportExportViewModel.Event.ShowOpeningShareCsvError -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.monthly_report_data_open_error_title) + .setMessage(R.string.monthly_report_data_open_error_description) + .setPositiveButton(R.string.ok, null) + .show() + } + MonthlyReportExportViewModel.Event.Finish -> finish() + } + } + } + + AppWithTopAppBarScaffold( + title = stringResource(R.string.title_activity_monthly_report_export), + backButtonBehavior = BackButtonBehavior.NavigateBack( + onBackButtonPressed = navigateUp, + ), + content = { contentPadding -> + Box( + modifier = Modifier.padding(contentPadding), + ) { + ContentView( + stateFlow = stateFlow, + onRetryButtonClicked = onRetryButtonClicked, + onDownloadButtonClicked = onDownloadButtonClicked, + ) + } + }, + ) +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/export/MonthlyReportExportViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/export/MonthlyReportExportViewModel.kt new file mode 100644 index 00000000..fdd9ba60 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/export/MonthlyReportExportViewModel.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.monthlyreport.export + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.helper.CurrencyHelper +import com.benoitletondor.easybudgetapp.helper.Logger +import com.benoitletondor.easybudgetapp.helper.MutableLiveFlow +import com.benoitletondor.easybudgetapp.helper.toFormattedString +import com.benoitletondor.easybudgetapp.injection.CurrentDBProvider +import com.benoitletondor.easybudgetapp.injection.requireDB +import com.benoitletondor.easybudgetapp.parameters.Parameters +import com.github.doyaaaaaken.kotlincsv.dsl.csvWriter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.time.YearMonth +import java.time.format.DateTimeFormatter + +@HiltViewModel(assistedFactory = MonthlyReportExportViewModelFactory::class) +class MonthlyReportExportViewModel @AssistedInject constructor( + private val dbProvider: CurrentDBProvider, + private val parameters: Parameters, + @Assisted private val month: YearMonth, + @ApplicationContext private val context: Context, +) : ViewModel() { + private val eventMutableFlow = MutableLiveFlow() + val eventFlow: Flow = eventMutableFlow + + private val retryMutableFlow = MutableSharedFlow() + + val stateFlow: StateFlow = flow { + val csvFile = withContext(Dispatchers.IO) { + val expenses = dbProvider.requireDB.getExpensesForMonth(month) + val file = File(context.cacheDir, tempFileName(month)) + val dateFormatter = DateTimeFormatter.ISO_DATE + + Logger.debug("Creating temporary file: ${file.path}") + csvWriter().openAsync(file) { + writeRow(listOf( + context.getString(R.string.monthly_report_data_date_row), + context.getString(R.string.monthly_report_data_title_row), + context.getString(R.string.monthly_report_data_amount_row), + context.getString(R.string.monthly_report_data_recurring_row), + context.getString(R.string.monthly_report_data_checked_row), + )) + for(expense in expenses) { + writeRow(listOf( + dateFormatter.format(expense.date), + expense.title, + CurrencyHelper.getFormattedCurrencyString(parameters, -expense.amount), + expense.associatedRecurringExpense?.recurringExpense?.type?.toFormattedString(context) ?: "", + if (expense.checked) "X" else "", + )) + } + } + + return@withContext file + } + + emit(State.Loaded(csvFile)) + } + .retryWhen { cause, _ -> + emit(State.Error(cause)) + Logger.error("Error creating month data CSV", cause) + + retryMutableFlow.first() + emit(State.Loading) + + true + } + .stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) + + override fun onCleared() { + val tempFile = File(context.cacheDir, tempFileName(month)) + if (tempFile.exists()) { + Logger.debug("Deleting temporary file: ${tempFile.path}") + tempFile.delete() + } + + super.onCleared() + } + + fun onRetryButtonClicked() { + viewModelScope.launch { + retryMutableFlow.emit(Unit) + } + } + + fun onDownloadButtonClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowShareCsv((stateFlow.value as State.Loaded).csvFile)) + } + } + + fun onErrorOpeningShareCsv(e: Exception) { + Logger.error("Error opening share CSV", e) + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowOpeningShareCsvError(e)) + } + } + + fun onShareCsvFinished(success: Boolean) { + if (success) { + viewModelScope.launch { + eventMutableFlow.emit(Event.Finish) + } + } + } + + sealed class State { + data object Loading : State() + data class Loaded(val csvFile: File) : State() + data class Error(val error: Throwable) : State() + } + + sealed class Event { + data class ShowShareCsv(val csvFile: File) : Event() + data class ShowOpeningShareCsvError(val error: Exception) : Event() + data object Finish : Event() + } +} + +private fun tempFileName(month: YearMonth): String = "export_${month.year}_${month.monthValue}.csv" + +@AssistedFactory +interface MonthlyReportExportViewModelFactory { + fun create(month: YearMonth): MonthlyReportExportViewModel +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/export/subviews/Views.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/export/subviews/Views.kt new file mode 100644 index 00000000..648a11f7 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/export/subviews/Views.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.monthlyreport.export.subviews + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.compose.AppTheme +import com.benoitletondor.easybudgetapp.compose.components.LoadingView +import com.benoitletondor.easybudgetapp.view.monthlyreport.export.MonthlyReportExportViewModel +import kotlinx.coroutines.flow.StateFlow +import java.lang.IllegalArgumentException + +@Composable +fun ContentView( + stateFlow: StateFlow, + onRetryButtonClicked: () -> Unit, + onDownloadButtonClicked: () -> Unit, +) { + val state by stateFlow.collectAsState() + + when(val currentState = state) { + is MonthlyReportExportViewModel.State.Error -> ErrorView( + error = currentState.error, + onRetryButtonClicked = onRetryButtonClicked, + ) + is MonthlyReportExportViewModel.State.Loaded -> LoadedView( + onDownloadButtonClicked = onDownloadButtonClicked, + ) + MonthlyReportExportViewModel.State.Loading -> LoadingView() + } +} + +@Composable +private fun ErrorView( + error: Throwable, + onRetryButtonClicked: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.monthly_report_data_loading_error_title), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.monthly_report_data_loading_error_description, error.localizedMessage ?: "No error message"), + fontSize = 16.sp, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Button( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = onRetryButtonClicked, + ) { + Text(stringResource(R.string.monthly_report_data_loading_error_cta)) + } + } +} + +@Composable +private fun LoadedView( + onDownloadButtonClicked: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.monthly_report_export_data_loaded_title), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Button( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = onDownloadButtonClicked, + ) { + Text( + text = stringResource(id = R.string.monthly_report_export_data_loaded_cta), + ) + } + } +} + +@Composable +@Preview(name = "Loading preview", showSystemUi = true) +private fun LoadingPreview() { + AppTheme { + LoadingView() + } +} + +@Composable +@Preview(name = "Error preview", showSystemUi = true) +private fun ErrorPreview() { + AppTheme { + ErrorView( + error = IllegalArgumentException("An error occurred"), + onRetryButtonClicked = {}, + ) + } +} + +@Composable +@Preview(name = "Success preview", showSystemUi = true) +private fun SuccessPreview() { + AppTheme { + LoadedView( + onDownloadButtonClicked = {}, + ) + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/EmptyView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/EmptyView.kt new file mode 100644 index 00000000..30c47dc5 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/EmptyView.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.monthlyreport.subviews + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@Composable +fun EmptyView() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp, vertical = 20.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(R.drawable.ic_date_grey_48dp), + contentDescription = null, + ) + + Spacer(modifier = Modifier.height(15.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.monthly_report_no_entries_placeholder), + color = colorResource(R.color.placeholder_text), + fontSize = 14.sp, + textAlign = TextAlign.Center, + ) + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/Entries.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/Entries.kt new file mode 100644 index 00000000..2e8d45d5 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/Entries.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.monthlyreport.subviews + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.model.Expense +import kotlinx.coroutines.flow.StateFlow +import java.util.Currency + +@Composable +fun EntriesView( + userCurrencyStateFlow: StateFlow, + expenses: List, + revenues: List, +) { + val currency by userCurrencyStateFlow.collectAsState() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(expenses.size + revenues.size + 2) { index -> + when(index) { + 0 -> { + Text( + modifier = Modifier + .fillMaxWidth() + .background(color = colorResource(R.color.budget_green)) + .padding(horizontal = 16.dp, vertical = 5.dp), + text = stringResource(R.string.revenues), + fontWeight = FontWeight.Bold, + color = Color.White, + fontSize = 16.sp, + ) + } + revenues.size + 1 -> { + Text( + modifier = Modifier + .fillMaxWidth() + .background(color = colorResource(R.color.budget_red)) + .padding(horizontal = 16.dp, vertical = 5.dp), + text = stringResource(R.string.expenses), + fontWeight = FontWeight.Bold, + color = Color.White, + fontSize = 16.sp, + ) + } + else -> { + when(index) { + in 1 until revenues.size + 1 -> { + val revenue = revenues.getOrNull(index - 1) + if (revenue != null) { + Entry( + currency = currency, + expense = revenue, + includeDivider = index < revenues.size, + ) + } + } + in revenues.size + 2 until revenues.size + expenses.size + 2 -> { + val expense = expenses.getOrNull(index - revenues.size - 2) + if (expense != null) { + Entry( + currency = currency, + expense = expense, + includeDivider = index < revenues.size + expenses.size, + ) + } + } + } + } + } + } + + item("bottomPadding") { + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/Entry.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/Entry.kt new file mode 100644 index 00000000..ff8c7c08 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/Entry.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.monthlyreport.subviews + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.paint +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.helper.CurrencyHelper +import com.benoitletondor.easybudgetapp.helper.toFormattedString +import com.benoitletondor.easybudgetapp.model.Expense +import java.util.Currency + +@Composable +fun Entry( + currency: Currency, + expense: Expense, + includeDivider: Boolean, +) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ){ + Text( + modifier = Modifier + .paint( + painter = painterResource(R.drawable.ic_date), + contentScale = ContentScale.FillBounds, + ) + .wrapContentHeight() + .padding(top = 4.dp), + text = expense.date.dayOfMonth.toString(), + fontSize = 14.sp, + color = colorResource(R.color.monthly_report_date_color), + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + style = TextStyle( + platformStyle = PlatformTextStyle( + includeFontPadding = false, + ), + ), + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = expense.title, + color = colorResource(R.color.primary_text), + fontSize = 16.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = CurrencyHelper.getFormattedCurrencyString(currency, expense.amount), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = colorResource(if (expense.isRevenue()) R.color.budget_green else R.color.budget_red), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (expense.isRecurring()) { + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.width(60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(R.drawable.ic_autorenew_grey_26dp), + contentDescription = null, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = TextStyle( + platformStyle = PlatformTextStyle( + includeFontPadding = false, + ), + ), + text = expense.associatedRecurringExpense!!.recurringExpense.type.toFormattedString( + LocalContext.current), + fontSize = 9.sp, + color = colorResource(R.color.secondary_text), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + if (includeDivider) { + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = colorResource(R.color.divider), + thickness = 1.dp, + ) + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/ErrorView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/ErrorView.kt new file mode 100644 index 00000000..1f2665c9 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/ErrorView.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.monthlyreport.subviews + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@Composable +fun ErrorView( + error: Throwable, + onRetryButtonClicked: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.calendar_month_loading_error_title), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.account_error_loading_message, error.localizedMessage ?: "No error message"), + fontSize = 16.sp, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Button( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = onRetryButtonClicked, + ) { + Text(stringResource(R.string.manage_account_error_cta)) + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/MonthHeader.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/MonthHeader.kt new file mode 100644 index 00000000..70ba368b --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/MonthHeader.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.monthlyreport.subviews + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.helper.getMonthTitle +import com.benoitletondor.easybudgetapp.view.monthlyreport.MonthlyReportSelectedPosition +import java.util.Locale + +@SuppressLint("PrivateResource") +@Composable +fun MonthsHeader( + selectedPosition: MonthlyReportSelectedPosition, + onPreviousMonthClicked: () -> Unit, + onNextMonthClicked: () -> Unit, +) { + val context = LocalContext.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = onPreviousMonthClicked, + enabled = !selectedPosition.first, + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = colorResource(R.color.monthly_report_month_switch_button), + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_navigate_before_24), + contentDescription = stringResource(androidx.compose.material3.R.string.m3c_date_picker_switch_to_previous_month), + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = selectedPosition.month.getMonthTitle(context).uppercase(Locale.getDefault()), + fontSize = 21.sp, + color = colorResource(R.color.primary_text), + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = onNextMonthClicked, + enabled = !selectedPosition.last, + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = colorResource(R.color.monthly_report_month_switch_button), + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_navigate_next_24), + contentDescription = stringResource(androidx.compose.material3.R.string.m3c_date_picker_switch_to_next_month), + ) + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/RecapView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/RecapView.kt new file mode 100644 index 00000000..36d68ee0 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/monthlyreport/subviews/RecapView.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.monthlyreport.subviews + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.helper.CurrencyHelper +import kotlinx.coroutines.flow.StateFlow +import java.util.Currency + +@Composable +fun RecapView( + userCurrencyStateFlow: StateFlow, + expensesAmount: Double, + revenuesAmount: Double, +) { + val currency by userCurrencyStateFlow.collectAsState() + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ){ + Column( + modifier = Modifier.weight(0.5f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.revenues_total), + color = colorResource(R.color.monthly_report_categories_title), + fontSize = 18.sp, + ) + + Text( + text = CurrencyHelper.getFormattedCurrencyString(currency, revenuesAmount), + color = colorResource(R.color.monthly_report_categories_value), + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(0.5f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.expenses_total), + color = colorResource(R.color.monthly_report_categories_title), + fontSize = 18.sp, + ) + + Text( + text = CurrencyHelper.getFormattedCurrencyString(currency, expensesAmount), + color = colorResource(R.color.monthly_report_categories_value), + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.balance), + color = colorResource(R.color.monthly_report_categories_title), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + + val balance = revenuesAmount - expensesAmount + + Text( + text = CurrencyHelper.getFormattedCurrencyString(currency, balance), + color = colorResource(if (balance >= 0) R.color.budget_green else R.color.budget_red), + fontSize = 25.sp, + fontWeight = FontWeight.Bold, + ) + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/OnboardingView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/OnboardingView.kt new file mode 100644 index 00000000..586d9c38 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/OnboardingView.kt @@ -0,0 +1,243 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.onboarding + +import android.os.Build +import android.os.Parcelable +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.benoitletondor.easybudgetapp.compose.rememberPermissionStateCompat +import com.benoitletondor.easybudgetapp.helper.launchCollect +import com.benoitletondor.easybudgetapp.view.onboarding.subviews.OnboardingPageAccountAmount +import com.benoitletondor.easybudgetapp.view.onboarding.subviews.OnboardingPageCurrency +import com.benoitletondor.easybudgetapp.view.onboarding.subviews.OnboardingPageEnd +import com.benoitletondor.easybudgetapp.view.onboarding.subviews.OnboardingPagePushNotifications +import com.benoitletondor.easybudgetapp.view.onboarding.subviews.OnboardingPageWelcome +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import java.lang.IllegalStateException +import java.util.Currency + +@Serializable +object OnboardingDestination + +@Parcelize +data class OnboardingResult(val onboardingCompleted: Boolean) : Parcelable + +@Composable +fun OnboardingView( + viewModel: OnboardingViewModel = hiltViewModel(), + finishWithResult: (OnboardingResult) -> Unit, +) { + OnboardingView( + eventFlow = viewModel.eventFlow, + userCurrencyFlow = viewModel.userCurrencyFlow, + userMoneyAmountFlow = viewModel.userMoneyAmountFlow, + finishWithResult = finishWithResult, + onBackPressed = viewModel::onBackPressed, + onNextButtonPressed = viewModel::onNextButtonPressed, + onAmountChange = viewModel::onAmountChange, + onAcceptNotificationsPressed = viewModel::onAcceptNotificationsPressed, + onDenyNotificationsPressed = viewModel::onDenyNotificationsPressed, + onPushNotificationsResponse = viewModel::onPushNotificationsResponse, + ) +} + +private val isAndroid33OrMore = Build.VERSION.SDK_INT >= 33 + +fun pageIndexToOnboardingPage(index: Int): OnboardingViewModel.OnboardingPage { + return when(index) { + 0 -> OnboardingViewModel.OnboardingPage.WELCOME + 1 -> OnboardingViewModel.OnboardingPage.CURRENCY + 2 -> OnboardingViewModel.OnboardingPage.INITIAL_AMOUNT + 3 -> if (isAndroid33OrMore) OnboardingViewModel.OnboardingPage.PUSH_NOTIFICATIONS else OnboardingViewModel.OnboardingPage.END + 4 -> OnboardingViewModel.OnboardingPage.END + else -> throw IllegalStateException("Unknown onboarding page index: $index") + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun OnboardingView( + eventFlow: Flow, + userCurrencyFlow: StateFlow, + userMoneyAmountFlow: StateFlow, + finishWithResult: (OnboardingResult) -> Unit, + onBackPressed: (page: OnboardingViewModel.OnboardingPage) -> Unit, + onNextButtonPressed: (page: OnboardingViewModel.OnboardingPage) -> Unit, + onAmountChange: (String) -> Unit, + onAcceptNotificationsPressed: () -> Unit, + onDenyNotificationsPressed: () -> Unit, + onPushNotificationsResponse: (OnboardingViewModel.OnboardingPage) -> Unit, +) { + val pagerState = rememberPagerState( + pageCount = { if (isAndroid33OrMore) 5 else 4 }, + ) + + val shouldFocusOnAccountAmountField by remember { + derivedStateOf { + pagerState.currentPageOffsetFraction == 0f && pageIndexToOnboardingPage(pagerState.currentPage) == OnboardingViewModel.OnboardingPage.INITIAL_AMOUNT + } + } + + // This is because of a weird bug that shows the keyboard after notification permission + val keyboardController = LocalSoftwareKeyboardController.current + LaunchedEffect(pagerState.currentPage, pagerState.currentPageOffsetFraction) { + if (shouldFocusOnAccountAmountField) { + keyboardController?.show() + } else { + keyboardController?.hide() + } + } + + val pushPermissionState = rememberPermissionStateCompat { + onPushNotificationsResponse(pageIndexToOnboardingPage(pagerState.currentPage)) + } + + LaunchedEffect(key1 = "eventsListener") { + launchCollect(eventFlow) { event -> + when(event) { + is OnboardingViewModel.Event.FinishWithResult -> finishWithResult(event.result) + OnboardingViewModel.Event.GoToPreviousPage -> { + pagerState.animateScrollToPage(page = pagerState.currentPage - 1) + } + OnboardingViewModel.Event.GoToNextPage -> { + pagerState.animateScrollToPage(page = pagerState.currentPage + 1) + } + OnboardingViewModel.Event.RequestPushPermission -> { + if (pushPermissionState.status.isGranted) { + onPushNotificationsResponse(pageIndexToOnboardingPage(pagerState.currentPage)) + } else { + pushPermissionState.launchPermissionRequest() + } + } + } + } + } + + BackHandler { + onBackPressed(pageIndexToOnboardingPage(pagerState.currentPage)) + } + + Scaffold( + content = { contentPadding -> + val layoutDirection = LocalLayoutDirection.current + val pageContentPadding = remember { + PaddingValues( + start = contentPadding.calculateStartPadding(layoutDirection) + 20.dp, + top = contentPadding.calculateTopPadding() + 16.dp, + end = contentPadding.calculateEndPadding(layoutDirection) + 20.dp, + bottom = contentPadding.calculateBottomPadding() + 32.dp, + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .imePadding() + ) { + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = pagerState, + ) {pageIndex -> + when(val page = pageIndexToOnboardingPage(pageIndex)) { + OnboardingViewModel.OnboardingPage.WELCOME -> OnboardingPageWelcome( + contentPadding = pageContentPadding, + onNextPressed = { onNextButtonPressed(page) }, + ) + OnboardingViewModel.OnboardingPage.CURRENCY -> OnboardingPageCurrency( + contentPadding = pageContentPadding, + userCurrencyFlow = userCurrencyFlow, + onNextPressed = { onNextButtonPressed(page) }, + ) + OnboardingViewModel.OnboardingPage.INITIAL_AMOUNT -> OnboardingPageAccountAmount( + contentPadding = pageContentPadding, + shouldFocusOnAccountAmountField = shouldFocusOnAccountAmountField, + userCurrencyFlow = userCurrencyFlow, + userMoneyAmountFlow = userMoneyAmountFlow, + onNextPressed = { onNextButtonPressed(page) }, + onAmountChange = onAmountChange, + ) + OnboardingViewModel.OnboardingPage.PUSH_NOTIFICATIONS -> OnboardingPagePushNotifications( + contentPadding = pageContentPadding, + onAcceptNotificationsPressed = onAcceptNotificationsPressed, + onDenyNotificationsPressed = onDenyNotificationsPressed, + ) + OnboardingViewModel.OnboardingPage.END -> OnboardingPageEnd( + contentPadding = pageContentPadding, + onNextPressed = { onNextButtonPressed(page) }, + ) + } + } + + Row( + Modifier + .padding(contentPadding) + .wrapContentHeight() + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(pagerState.pageCount) { iteration -> + val color = if (pagerState.currentPage == iteration) Color.White else Color.White.copy(alpha = .6f) + Box( + modifier = Modifier + .padding(horizontal = 4.dp) + .clip(CircleShape) + .background(color) + .size(if (pagerState.currentPage == iteration) 8.dp else 5.dp) + ) + } + } + } + } + ) +} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/OnboardingViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/OnboardingViewModel.kt new file mode 100644 index 00000000..93a7c917 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/OnboardingViewModel.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.onboarding + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.db.DB +import com.benoitletondor.easybudgetapp.helper.Logger +import com.benoitletondor.easybudgetapp.helper.MutableLiveFlow +import com.benoitletondor.easybudgetapp.helper.watchUserCurrency +import com.benoitletondor.easybudgetapp.model.Expense +import com.benoitletondor.easybudgetapp.parameters.ONBOARDING_STEP_COMPLETED +import com.benoitletondor.easybudgetapp.parameters.Parameters +import com.benoitletondor.easybudgetapp.parameters.setOnboardingStep +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.LocalDate +import javax.inject.Inject + +@HiltViewModel +class OnboardingViewModel @Inject constructor( + private val parameters: Parameters, + private val db: DB, + @ApplicationContext private val context: Context, +) : ViewModel() { + private val mutableEventFlow: MutableLiveFlow = MutableLiveFlow() + val eventFlow: Flow = mutableEventFlow + + private val userMoneyAmountMutableFlow = MutableStateFlow(0.0) + val userMoneyAmountFlow: StateFlow = userMoneyAmountMutableFlow + + init { + viewModelScope.launch(Dispatchers.IO) { + userMoneyAmountMutableFlow.value = -db.getBalanceForDay(LocalDate.now()) + } + } + + val userCurrencyFlow get() = parameters.watchUserCurrency() + + fun onBackPressed(page: OnboardingPage) { + viewModelScope.launch { + if (page == OnboardingPage.WELCOME) { + mutableEventFlow.emit(Event.FinishWithResult(OnboardingResult(onboardingCompleted = false))) + } else { + mutableEventFlow.emit(Event.GoToPreviousPage) + } + } + } + + fun onNextButtonPressed(page: OnboardingPage) { + viewModelScope.launch { + when(page) { + OnboardingPage.WELCOME, + OnboardingPage.CURRENCY, + OnboardingPage.PUSH_NOTIFICATIONS -> mutableEventFlow.emit(Event.GoToNextPage) + OnboardingPage.INITIAL_AMOUNT -> { + viewModelScope.launch(Dispatchers.IO) { + val currentBalance = -db.getBalanceForDay(LocalDate.now()) + val amountParsed = userMoneyAmountMutableFlow.value + if (amountParsed != currentBalance) { + val diff = amountParsed - currentBalance + + val existingBalanceExpense = db.getExpensesForDay(LocalDate.now()).firstOrNull { it.title == context.getString(R.string.adjust_balance_expense_title) } + val expense = existingBalanceExpense?.copy(amount = -diff) + ?: Expense(context.getString(R.string.adjust_balance_expense_title), -diff, LocalDate.now(), true) + + db.persistExpense(expense) + } + + withContext(Dispatchers.Main) { + mutableEventFlow.emit(Event.GoToNextPage) + } + } + } + OnboardingPage.END -> { + parameters.setOnboardingStep(ONBOARDING_STEP_COMPLETED) + mutableEventFlow.emit(Event.FinishWithResult(OnboardingResult(onboardingCompleted = true))) + } + } + } + } + + fun onAmountChange(amount: String) { + val amountParsed = parseAmountValue(amount) + userMoneyAmountMutableFlow.value = amountParsed + } + + fun onAcceptNotificationsPressed() { + viewModelScope.launch { + mutableEventFlow.emit(Event.RequestPushPermission) + } + } + + fun onDenyNotificationsPressed() { + viewModelScope.launch { + mutableEventFlow.emit(Event.GoToNextPage) + } + } + + fun onPushNotificationsResponse(onboardingPage: OnboardingPage) { + if (onboardingPage == OnboardingPage.PUSH_NOTIFICATIONS) { + viewModelScope.launch { + mutableEventFlow.emit(Event.GoToNextPage) + } + } + } + + sealed class Event { + data class FinishWithResult(val result: OnboardingResult) : Event() + data object GoToPreviousPage : Event() + data object GoToNextPage : Event() + data object RequestPushPermission : Event() + } + + enum class OnboardingPage { + WELCOME, + CURRENCY, + INITIAL_AMOUNT, + PUSH_NOTIFICATIONS, + END, + } +} + +private fun parseAmountValue(valueString: String): Double { + return try { + if ( "" == valueString || "-" == valueString) 0.0 else java.lang.Double.valueOf(valueString.replace(",", ".")) + } catch (e: Exception) { + Logger.warning("An error occurred during initial amount parsing: $valueString", e) + return 0.0 + } +} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageAccountAmount.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageAccountAmount.kt new file mode 100644 index 00000000..8452b2b8 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageAccountAmount.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.onboarding.subviews + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.helper.CurrencyHelper +import com.benoitletondor.easybudgetapp.helper.sanitizeFromUnsupportedInputForDecimals +import kotlinx.coroutines.flow.StateFlow +import java.util.Currency + +@Composable +fun OnboardingPageAccountAmount( + contentPadding: PaddingValues, + shouldFocusOnAccountAmountField: Boolean, + userCurrencyFlow: StateFlow, + userMoneyAmountFlow: StateFlow, + onNextPressed: () -> Unit, + onAmountChange: (String) -> Unit, +) { + val currency by userCurrencyFlow.collectAsState() + val currentAmount by userMoneyAmountFlow.collectAsState() + + var currentTextFieldValue by remember { mutableStateOf( + TextFieldValue( + text = "", + selection = TextRange(index = 0), + ) + ) } + + LaunchedEffect("initAmount") { + val amountFromDB = userMoneyAmountFlow.value + val formattedAmount = formatAmountValue(amountFromDB) + if (amountFromDB != 0.0 && formattedAmount != currentTextFieldValue.text) { + currentTextFieldValue = TextFieldValue( + text = formattedAmount, + selection = TextRange(index = formattedAmount.length), + ) + } + } + + val focusRequester = remember { FocusRequester() } + LaunchedEffect(shouldFocusOnAccountAmountField) { + if (shouldFocusOnAccountAmountField) { + focusRequester.requestFocus() + } else { + focusRequester.freeFocus() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(color = colorResource(R.color.secondary)) + .padding(contentPadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(bottom = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.onboarding_screen_3_title), + color = Color.White, + fontSize = 30.sp, + textAlign = TextAlign.Center, + lineHeight = 36.sp, + ) + + Spacer(modifier = Modifier.height(30.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.onboarding_screen_3_message), + color = Color.White, + fontSize = 20.sp, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Row( + modifier = Modifier.fillMaxWidth(0.7f), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ){ + TextField( + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + value = currentTextFieldValue, + onValueChange = { newValue -> + val newText = newValue.text.sanitizeFromUnsupportedInputForDecimals() + + currentTextFieldValue = TextFieldValue( + text = newText, + selection = newValue.selection, + ) + onAmountChange(newText) + }, + textStyle = TextStyle( + fontSize = 20.sp, + ), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + autoCorrectEnabled = false, + ), + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = currency.symbol, + color = Color.White, + fontSize = 30.sp, + ) + } + + } + + Button( + onClick = onNextPressed, + ) { + Text( + text = stringResource(R.string.onboarding_screen_3_cta, CurrencyHelper.getFormattedCurrencyString(currency, currentAmount)), + fontSize = 20.sp, + ) + } + } +} + +private fun formatAmountValue(amount: Double): String = if (amount == 0.0) "0" else amount.toString() \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageCurrency.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageCurrency.kt new file mode 100644 index 00000000..56fb2af8 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageCurrency.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.onboarding.subviews + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.view.selectcurrency.SelectCurrencyView +import kotlinx.coroutines.flow.StateFlow +import java.util.Currency + +@Composable +fun OnboardingPageCurrency( + contentPadding: PaddingValues, + userCurrencyFlow: StateFlow, + onNextPressed: () -> Unit, +) { + val selectedCurrency by userCurrencyFlow.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .background(color = colorResource(R.color.secondary)) + .padding(contentPadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(bottom = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.onboarding_screen_2_title), + color = Color.White, + fontSize = 30.sp, + textAlign = TextAlign.Center, + lineHeight = 36.sp, + ) + + Spacer(modifier = Modifier.height(30.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.onboarding_screen_2_message), + color = Color.White, + fontSize = 20.sp, + textAlign = TextAlign.Center, + ) + + SelectCurrencyView( + modifier = Modifier + .fillMaxWidth() + .weight(weight = 1f, fill = false) + .padding(horizontal = 20.dp, vertical = 30.dp) + .background(color = Color.White), + ) + } + + Button( + onClick = onNextPressed, + ) { + Text( + text = stringResource(R.string.onboarding_screen_2_cta, selectedCurrency.symbol), + fontSize = 20.sp, + ) + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageEnd.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageEnd.kt new file mode 100644 index 00000000..cbde2d4c --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageEnd.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.onboarding.subviews + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@Composable +fun OnboardingPageEnd( + contentPadding: PaddingValues, + onNextPressed: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(color = colorResource(R.color.easy_budget_green)) + .padding(contentPadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(bottom = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(R.drawable.ic_done_white_48dp), + contentDescription = null, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.onboarding_screen_4_title), + color = Color.White, + fontSize = 30.sp, + textAlign = TextAlign.Center, + lineHeight = 36.sp, + ) + + Spacer(modifier = Modifier.height(5.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.onboarding_screen_4_message), + color = Color.White, + fontSize = 18.sp, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(50.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.onboarding_screen_4_message2), + color = Color.White, + fontSize = 20.sp, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.onboarding_screen_4_message3), + color = Color.White, + fontSize = 18.sp, + ) + + Spacer(modifier = Modifier.height(50.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.onboarding_screen_4_message4), + color = Color.White, + fontSize = 20.sp, + textAlign = TextAlign.Center, + ) + } + + Button( + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = onNextPressed, + ) { + Text( + text = stringResource(R.string.onboarding_screen_4_cta), + fontSize = 20.sp, + ) + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPagePushNotification.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPagePushNotification.kt new file mode 100644 index 00000000..8a60a7cf --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPagePushNotification.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.onboarding.subviews + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@Composable +fun OnboardingPagePushNotifications( + contentPadding: PaddingValues, + onAcceptNotificationsPressed: () -> Unit, + onDenyNotificationsPressed: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(color = colorResource(R.color.secondary)) + .padding(contentPadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(bottom = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(R.drawable.ic_baseline_notification_important_24), + contentDescription = null, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.onboarding_screen_push_permission_title), + color = Color.White, + fontSize = 30.sp, + textAlign = TextAlign.Center, + lineHeight = 36.sp, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.onboarding_screen_push_permission_message), + color = Color.White, + fontSize = 18.sp, + textAlign = TextAlign.Center, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Button( + modifier = Modifier.weight(0.5f), + onClick = onDenyNotificationsPressed, + colors = ButtonDefaults.buttonColors( + containerColor = colorResource(R.color.accent_ripple), + contentColor = colorResource(R.color.easy_budget_green_dark), + ), + ) { + Text( + text = stringResource(R.string.onboarding_screen_push_permission_not_now_cta), + fontSize = 20.sp, + ) + } + + Spacer(modifier = Modifier.width(10.dp)) + + Button( + modifier = Modifier.weight(0.5f), + onClick = onAcceptNotificationsPressed, + ) { + Text( + text = stringResource(R.string.onboarding_screen_push_permission_accept_cta), + fontSize = 20.sp, + ) + } + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageWelcome.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageWelcome.kt new file mode 100644 index 00000000..032b224f --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/onboarding/subviews/OnboardingPageWelcome.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.onboarding.subviews + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@Composable +fun OnboardingPageWelcome( + contentPadding: PaddingValues, + onNextPressed: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(color = colorResource(R.color.easy_budget_green)) + .padding(contentPadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(bottom = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(R.drawable.ic_launcher), + contentDescription = null, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.onboarding_screen_1_title), + color = Color.White, + fontSize = 40.sp, + textAlign = TextAlign.Center, + lineHeight = 46.sp, + ) + + Spacer(modifier = Modifier.height(60.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.onboarding_screen_1_message), + color = Color.White, + fontSize = 16.sp, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(40.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.onboarding_screen_1_message2), + color = Color.White, + fontSize = 30.sp, + textAlign = TextAlign.Center, + ) + } + + Button( + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = onNextPressed, + ) { + Text( + text = stringResource(R.string.onboarding_screen_1_cta), + fontSize = 20.sp, + ) + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/PremiumActivity.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/PremiumActivity.kt deleted file mode 100644 index 322f3696..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/PremiumActivity.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.premium - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.lifecycleScope -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.helper.launchCollect -import com.benoitletondor.easybudgetapp.helper.setNavigationBarColored -import com.benoitletondor.easybudgetapp.helper.setStatusBarColor -import com.benoitletondor.easybudgetapp.iab.PurchaseFlowResult -import com.benoitletondor.easybudgetapp.theme.AppTheme -import com.benoitletondor.easybudgetapp.theme.easyBudgetGreenColor -import com.benoitletondor.easybudgetapp.view.premium.view.ErrorView -import com.benoitletondor.easybudgetapp.view.premium.view.LoadingView -import com.benoitletondor.easybudgetapp.view.premium.view.SubscribeView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class PremiumActivity : AppCompatActivity() { - private val viewModel: PremiumViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - setTheme(R.style.AppTheme) - - super.onCreate(savedInstanceState) - - // Cancelled by default - setResult(Activity.RESULT_CANCELED) - - setContent { - AppTheme { - Box(modifier = Modifier - .background(easyBudgetGreenColor) - .fillMaxWidth() - .fillMaxHeight() - ) { - val state by viewModel.userSubscriptionStatus.collectAsState(PremiumViewModel.SubscriptionStatus.Verifying) - - when (val currentState = state) { - is PremiumViewModel.WithPricing -> SubscribeView( - currentState.pricing, - showProByDefault = intent.getBooleanExtra(EXTRA_SHOW_PRO, false), - premiumSubscribed = currentState is PremiumViewModel.SubscriptionStatus.PremiumSubscribed || currentState is PremiumViewModel.SubscriptionStatus.ProSubscribed, - proSubscribed = currentState is PremiumViewModel.SubscriptionStatus.ProSubscribed, - onCancelButtonClicked = this@PremiumActivity::finish, - onBuyPremiumButtonClicked = { - viewModel.onBuyPremiumClicked(this@PremiumActivity) - }, - onBuyProButtonClicked = { - viewModel.onBuyProClicked(this@PremiumActivity) - } - ) - - PremiumViewModel.SubscriptionStatus.Verifying -> LoadingView() - PremiumViewModel.SubscriptionStatus.Error -> ErrorView( - onRetryButtonPressed = viewModel::onRetryButtonPressed, - onCloseButtonPressed = viewModel::onCloseButtonPressed, - ) - } - } - } - } - - setStatusBarColor(R.color.easy_budget_green) - setNavigationBarColored() - - collectViewModelEvents() - } - - private fun collectViewModelEvents() { - lifecycleScope.launchCollect(viewModel.premiumPurchaseEventFlow) { purchaseResult -> - when(purchaseResult) { - PurchaseFlowResult.Cancelled -> Unit - is PurchaseFlowResult.Error -> { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.iab_purchase_error_title) - .setMessage(getString(R.string.iab_purchase_error_message, purchaseResult.reason)) - .setPositiveButton(R.string.ok) { dialog, _ -> - dialog.dismiss() - } - .show() - } - is PurchaseFlowResult.Success -> { - setResult(Activity.RESULT_OK) - finish() - } - } - } - - lifecycleScope.launchCollect(viewModel.proPurchaseEventFlow) { purchaseResult -> - when(purchaseResult) { - PurchaseFlowResult.Cancelled -> Unit - is PurchaseFlowResult.Error -> { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.iab_purchase_error_title) - .setMessage(getString(R.string.iab_purchase_error_message, purchaseResult.reason)) - .setPositiveButton(R.string.ok) { dialog, _ -> - dialog.dismiss() - } - .show() - } - is PurchaseFlowResult.Success -> { - setResult(Activity.RESULT_OK) - finish() - } - } - } - - lifecycleScope.launchCollect(viewModel.eventFlow) { event -> - when(event) { - PremiumViewModel.Event.Finish -> finish() - } - } - } - - companion object { - private const val EXTRA_SHOW_PRO = "showPro" - - fun createIntent(activity: Activity, shouldShowProByDefault: Boolean): Intent { - return Intent(activity, PremiumActivity::class.java).apply { - putExtra(EXTRA_SHOW_PRO, shouldShowProByDefault) - } - } - } -} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/PremiumView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/PremiumView.kt new file mode 100644 index 00000000..602aa981 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/PremiumView.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.premium + +import android.app.Activity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.compose.easyBudgetGreenColor +import com.benoitletondor.easybudgetapp.compose.rememberPermissionStateCompat +import com.benoitletondor.easybudgetapp.helper.launchCollect +import com.benoitletondor.easybudgetapp.iab.PurchaseFlowResult +import com.benoitletondor.easybudgetapp.view.premium.view.ErrorView +import com.benoitletondor.easybudgetapp.view.premium.view.LoadingView +import com.benoitletondor.easybudgetapp.view.premium.view.SubscribeView +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.Serializable + +@Serializable +data class PremiumDestination(val startOnPro: Boolean) + +@Composable +fun PremiumView( + viewModel: PremiumViewModel = hiltViewModel(), + startOnPro: Boolean, + close: () -> Unit, +) { + val context = LocalContext.current + + PremiumView( + startOnPro = startOnPro, + eventFlow = viewModel.eventFlow, + userSubscriptionStatusFlow = viewModel.userSubscriptionStatusFlow, + onCancelButtonClicked = viewModel::onCancelButtonClicked, + onBuyPremiumClicked = { + (context as? Activity)?.let { + viewModel.onBuyPremiumClicked(it) + } + }, + onBuyProClicked = { + (context as? Activity)?.let { + viewModel.onBuyProClicked(it) + } + }, + onRetryButtonPressed = viewModel::onRetryButtonPressed, + onCloseButtonPressed = viewModel::onCloseButtonPressed, + onPushPermissionResult = viewModel::onPushPermissionResult, + close = close, + ) +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun PremiumView( + startOnPro: Boolean, + eventFlow: Flow, + userSubscriptionStatusFlow: StateFlow, + onCancelButtonClicked: () -> Unit, + onBuyPremiumClicked: () -> Unit, + onBuyProClicked: () -> Unit, + onRetryButtonPressed: () -> Unit, + onCloseButtonPressed: () -> Unit, + onPushPermissionResult: () -> Unit, + close: () -> Unit, +) { + val context = LocalContext.current + + val pushPermissionState = rememberPermissionStateCompat { + onPushPermissionResult() + } + + LaunchedEffect("eventsListener") { + launchCollect(eventFlow) { event -> + when(event) { + PremiumViewModel.Event.Finish -> close() + is PremiumViewModel.Event.PremiumPurchaseResult -> when(event.result) { + PurchaseFlowResult.Cancelled -> Unit + is PurchaseFlowResult.Error -> MaterialAlertDialogBuilder(context) + .setTitle(R.string.iab_purchase_error_title) + .setMessage(context.getString(R.string.iab_purchase_error_message, event.result.reason)) + .setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + } + .show() + is PurchaseFlowResult.Success -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.iab_purchase_success_title) + .setMessage(R.string.iab_purchase_success_message) + .setPositiveButton(R.string.ok) { dialog, _ -> dialog.dismiss() } + .setOnDismissListener { + if (pushPermissionState.status.isGranted) { + onPushPermissionResult() + } else { + pushPermissionState.launchPermissionRequest() + } + } + .show() + } + } + is PremiumViewModel.Event.ProPurchaseResult -> when(event.result) { + PurchaseFlowResult.Cancelled -> Unit + is PurchaseFlowResult.Error -> MaterialAlertDialogBuilder(context) + .setTitle(R.string.iab_purchase_error_title) + .setMessage(context.getString(R.string.iab_purchase_error_message, event.result.reason)) + .setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + } + .show() + is PurchaseFlowResult.Success -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.iab_purchase_success_title) + .setMessage(R.string.iab_purchase_success_message) + .setPositiveButton(R.string.ok) { dialog, _ -> dialog.dismiss() } + .setOnDismissListener { + if (pushPermissionState.status.isGranted) { + onPushPermissionResult() + } else { + pushPermissionState.launchPermissionRequest() + } + } + .show() + } + } + } + } + } + + Scaffold( + content = { contentPaddingValues -> + Box(modifier = Modifier + .fillMaxSize() + .background(easyBudgetGreenColor) + .padding(contentPaddingValues) + ) { + val state by userSubscriptionStatusFlow.collectAsState() + + when (val currentState = state) { + is PremiumViewModel.WithPricing -> SubscribeView( + currentState.pricing, + showProByDefault = startOnPro, + premiumSubscribed = currentState is PremiumViewModel.SubscriptionStatus.PremiumSubscribed || currentState is PremiumViewModel.SubscriptionStatus.ProSubscribed, + proSubscribed = currentState is PremiumViewModel.SubscriptionStatus.ProSubscribed, + onCancelButtonClicked = onCancelButtonClicked, + onBuyPremiumButtonClicked = onBuyPremiumClicked, + onBuyProButtonClicked = onBuyProClicked, + ) + + PremiumViewModel.SubscriptionStatus.Verifying -> LoadingView() + PremiumViewModel.SubscriptionStatus.Error -> ErrorView( + onRetryButtonPressed = onRetryButtonPressed, + onCloseButtonPressed = onCloseButtonPressed, + ) + } + } + } + ) +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/PremiumViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/PremiumViewModel.kt index cb286727..7e3fa21e 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/PremiumViewModel.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/PremiumViewModel.kt @@ -25,18 +25,18 @@ import com.benoitletondor.easybudgetapp.iab.Iab import com.benoitletondor.easybudgetapp.iab.PremiumCheckStatus import com.benoitletondor.easybudgetapp.iab.Pricing import com.benoitletondor.easybudgetapp.iab.PurchaseFlowResult -import com.benoitletondor.easybudgetapp.iab.PurchaseType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -46,11 +46,13 @@ class PremiumViewModel @Inject constructor( ) : ViewModel() { private val errorRetryMutableSharedFlow = MutableSharedFlow() - private val eventMutableSharedFlow = MutableSharedFlow() + private val eventMutableSharedFlow = MutableLiveFlow() val eventFlow: Flow = eventMutableSharedFlow + private var shouldFinishOnPermissionResult = false + @OptIn(ExperimentalCoroutinesApi::class) - val userSubscriptionStatus: Flow = flow { emit(iab.fetchPricingOrDefault()) } + val userSubscriptionStatusFlow: StateFlow = flow { emit(iab.fetchPricingOrDefault()) } .flatMapLatest { pricing -> iab.iabStatusFlow .map { iabStatus -> @@ -71,18 +73,7 @@ class PremiumViewModel @Inject constructor( true } - - private val premiumPurchaseStatusMutableFlow = MutableStateFlow(PurchaseFlowStatus.NOT_STARTED) - val premiumPurchaseStatusFlow: Flow = premiumPurchaseStatusMutableFlow - - private val premiumPurchaseEventMutableFlow = MutableLiveFlow() - val premiumPurchaseEventFlow: Flow = premiumPurchaseEventMutableFlow - - private val proPurchaseStatusMutableFlow = MutableStateFlow(PurchaseFlowStatus.NOT_STARTED) - val proPurchaseStatusFlow: Flow = proPurchaseStatusMutableFlow - - private val proPurchaseEventMutableFlow = MutableLiveFlow() - val proPurchaseEventFlow: Flow = proPurchaseEventMutableFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, SubscriptionStatus.Verifying) fun onRetryButtonPressed() { viewModelScope.launch { @@ -96,58 +87,40 @@ class PremiumViewModel @Inject constructor( } } - fun onBuyPremiumClicked(activity: Activity) { - premiumPurchaseStatusMutableFlow.value = PurchaseFlowStatus.LOADING - + fun onCancelButtonClicked() { viewModelScope.launch { - when(val result = iab.launchPremiumSubscriptionFlow(activity)) { - PurchaseFlowResult.Cancelled -> { - premiumPurchaseEventMutableFlow.emit(result) - premiumPurchaseStatusMutableFlow.value = PurchaseFlowStatus.NOT_STARTED - } - is PurchaseFlowResult.Success -> { - if (result.purchaseType == PurchaseType.PREMIUM_SUBSCRIPTION) { - premiumPurchaseEventMutableFlow.emit(result) - premiumPurchaseStatusMutableFlow.value = PurchaseFlowStatus.DONE - } - } - is PurchaseFlowResult.Error -> { - Logger.error("Error while launching premium purchase flow: ${result.reason}") - premiumPurchaseEventMutableFlow.emit(result) - premiumPurchaseStatusMutableFlow.value = PurchaseFlowStatus.NOT_STARTED - } - } + eventMutableSharedFlow.emit(Event.Finish) } } - fun onBuyProClicked(activity: Activity) { - premiumPurchaseStatusMutableFlow.value = PurchaseFlowStatus.LOADING + fun onPushPermissionResult() { + if (shouldFinishOnPermissionResult) { + viewModelScope.launch { + eventMutableSharedFlow.emit(Event.Finish) + } + } + } + fun onBuyPremiumClicked(activity: Activity) { viewModelScope.launch { - when(val result = iab.launchProSubscriptionFlow(activity)) { - PurchaseFlowResult.Cancelled -> { - proPurchaseEventMutableFlow.emit(result) - proPurchaseStatusMutableFlow.value = PurchaseFlowStatus.NOT_STARTED - } - is PurchaseFlowResult.Success -> { - if (result.purchaseType == PurchaseType.PRO_SUBSCRIPTION) { - proPurchaseEventMutableFlow.emit(result) - proPurchaseStatusMutableFlow.value = PurchaseFlowStatus.DONE - } - } - is PurchaseFlowResult.Error -> { - Logger.error("Error while launching pro purchase flow: ${result.reason}") - proPurchaseEventMutableFlow.emit(result) - proPurchaseStatusMutableFlow.value = PurchaseFlowStatus.NOT_STARTED - } + val result = iab.launchPremiumSubscriptionFlow(activity) + if (result is PurchaseFlowResult.Success) { + shouldFinishOnPermissionResult = true } + + eventMutableSharedFlow.emit(Event.PremiumPurchaseResult(result)) } } - enum class PurchaseFlowStatus { - NOT_STARTED, - LOADING, - DONE + fun onBuyProClicked(activity: Activity) { + viewModelScope.launch { + val result = iab.launchProSubscriptionFlow(activity) + if (result is PurchaseFlowResult.Success) { + shouldFinishOnPermissionResult = true + } + + eventMutableSharedFlow.emit(Event.ProPurchaseResult(result)) + } } sealed class SubscriptionStatus { @@ -164,6 +137,8 @@ class PremiumViewModel @Inject constructor( sealed class Event { data object Finish : Event() + data class PremiumPurchaseResult(val result: PurchaseFlowResult) : Event() + data class ProPurchaseResult(val result: PurchaseFlowResult) : Event() } } diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/view/ErrorView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/view/ErrorView.kt index 900760e4..04fe3552 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/view/ErrorView.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/view/ErrorView.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.benoitletondor.easybudgetapp.view.premium.view import androidx.compose.foundation.background @@ -24,8 +39,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.theme.AppTheme -import com.benoitletondor.easybudgetapp.theme.easyBudgetGreenColor +import com.benoitletondor.easybudgetapp.compose.AppTheme +import com.benoitletondor.easybudgetapp.compose.easyBudgetGreenColor @Composable fun ErrorView( diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/view/SubscribeView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/view/SubscribeView.kt index ff014c39..0276449b 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/view/SubscribeView.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/premium/view/SubscribeView.kt @@ -56,7 +56,6 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -64,10 +63,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.benoitletondor.easybudgetapp.R import com.benoitletondor.easybudgetapp.iab.Pricing -import com.benoitletondor.easybudgetapp.theme.AppTheme -import com.benoitletondor.easybudgetapp.theme.easyBudgetGreenColor -import com.benoitletondor.easybudgetapp.theme.easyBudgetGreenDarkColor -import com.benoitletondor.easybudgetapp.view.premium.PremiumViewModel +import com.benoitletondor.easybudgetapp.compose.AppTheme +import com.benoitletondor.easybudgetapp.compose.easyBudgetGreenColor +import com.benoitletondor.easybudgetapp.compose.easyBudgetGreenDarkColor private val starsYellowColor = Color(0xFFFEE101) private val starsGreyColor = Color(0xFFD7D7D7) diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/recurringexpenseadd/RecurringExpenseEditActivity.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/recurringexpenseadd/RecurringExpenseEditActivity.kt deleted file mode 100644 index 55df5445..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/recurringexpenseadd/RecurringExpenseEditActivity.kt +++ /dev/null @@ -1,373 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.recurringexpenseadd - -import android.app.ProgressDialog -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.MenuItem -import android.widget.ArrayAdapter -import androidx.activity.viewModels -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.databinding.ActivityRecurringExpenseAddBinding -import com.benoitletondor.easybudgetapp.helper.* -import com.benoitletondor.easybudgetapp.model.Expense -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.benoitletondor.easybudgetapp.model.RecurringExpenseType -import com.benoitletondor.easybudgetapp.view.DatePickerDialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.util.* -import javax.inject.Inject -import kotlin.math.abs - -@AndroidEntryPoint -class RecurringExpenseEditActivity : BaseActivity() { - private val viewModel: RecurringExpenseEditViewModel by viewModels() - - @Inject lateinit var parameters: Parameters - -// -------------------------------------------> - - override fun createBinding(): ActivityRecurringExpenseAddBinding = ActivityRecurringExpenseAddBinding.inflate(layoutInflater) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setSupportActionBar(binding.toolbar) - - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - setUpButtons() - - binding.descriptionEdittext.setFocus() - binding.saveExpenseFab.animateFABAppearance() - - lifecycleScope.launchCollect(viewModel.editTypeFlow) { (isRevenue, isEditing) -> - setExpenseTypeTextViewLayout(isRevenue, isEditing) - } - - val existingExpenseData = viewModel.existingExpenseData - if (existingExpenseData != null) { - setUpTextFields( - existingExpenseData.title, - existingExpenseData.amount, - type = existingExpenseData.type - ) - } else { - setUpTextFields(description = null, amount = null, type = null) - } - - lifecycleScope.launchCollect(viewModel.expenseDateFlow) { date -> - setUpDateButton(date) - } - - var progressDialog: ProgressDialog? = null - lifecycleScope.launchCollect(viewModel.savingStateFlow) { savingState -> - when(savingState) { - RecurringExpenseEditViewModel.SavingState.Idle -> { - progressDialog?.dismiss() - progressDialog = null - } - is RecurringExpenseEditViewModel.SavingState.Saving -> { - progressDialog?.dismiss() - progressDialog = null - - // Show a ProgressDialog - val dialog = ProgressDialog(this) - dialog.isIndeterminate = true - dialog.setTitle(R.string.recurring_expense_add_loading_title) - dialog.setMessage(getString(if (savingState.isRevenue) R.string.recurring_income_add_loading_message else R.string.recurring_expense_add_loading_message)) - dialog.setCanceledOnTouchOutside(false) - dialog.setCancelable(false) - dialog.show() - - progressDialog = dialog - } - } - - } - - lifecycleScope.launchCollect(viewModel.finishFlow) { - progressDialog?.dismiss() - progressDialog = null - - finish() - } - - lifecycleScope.launchCollect(viewModel.errorFlow) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.recurring_expense_add_error_title) - .setMessage(getString(R.string.recurring_expense_add_error_message)) - .setNegativeButton(R.string.ok) { dialog, _ -> dialog.dismiss() } - .show() - } - - lifecycleScope.launchCollect(viewModel.expenseAddBeforeInitDateEventFlow) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.expense_add_before_init_date_dialog_title) - .setMessage(R.string.expense_add_before_init_date_dialog_description) - .setPositiveButton(R.string.expense_add_before_init_date_dialog_positive_cta) { _, _ -> - viewModel.onAddExpenseBeforeInitDateConfirmed( - getCurrentAmount(), - binding.descriptionEdittext.text.toString(), - getRecurringTypeFromSpinnerSelection(binding.expenseTypeSpinner.selectedItemPosition) - ) - } - .setNegativeButton(R.string.expense_add_before_init_date_dialog_negative_cta) { _, _ -> - viewModel.onAddExpenseBeforeInitDateCancelled() - } - .show() - } - - lifecycleScope.launchCollect(viewModel.unableToLoadDBEventFlow) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.expense_edit_unable_to_load_db_error_title) - .setMessage(R.string.expense_edit_unable_to_load_db_error_message) - .setPositiveButton(R.string.expense_edit_unable_to_load_db_error_cta) { _, _ -> - finish() - } - .setCancelable(false) - .show() - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - finish() - return true - } - } - - return super.onOptionsItemSelected(item) - } - -// -----------------------------------> - - /** - * Validate user inputs - * - * @return true if user inputs are ok, false otherwise - */ - private fun validateInputs(): Boolean { - var ok = true - - val description = binding.descriptionEdittext.text.toString() - if (description.trim { it <= ' ' }.isEmpty()) { - binding.descriptionEdittext.error = resources.getString(R.string.no_description_error) - ok = false - } - - val amount = binding.amountEdittext.text.toString() - if (amount.trim { it <= ' ' }.isEmpty()) { - binding.amountEdittext.error = resources.getString(R.string.no_amount_error) - ok = false - } else { - try { - val value = java.lang.Double.parseDouble(amount) - if (value <= 0) { - binding.amountEdittext.error = resources.getString(R.string.negative_amount_error) - ok = false - } - } catch (e: Exception) { - binding.amountEdittext.error = resources.getString(R.string.invalid_amount) - ok = false - } - - } - - return ok - } - - /** - * Set-up revenue and payment buttons - */ - private fun setUpButtons() { - binding.expenseTypeSwitch.setOnCheckedChangeListener { _, isChecked -> - viewModel.onExpenseRevenueValueChanged(isChecked) - } - - binding.expenseTypeTv.setOnClickListener { - viewModel.onExpenseRevenueValueChanged(!binding.expenseTypeSwitch.isChecked) - } - - binding.saveExpenseFab.setOnClickListener { - if (validateInputs()) { - viewModel.onSave( - getCurrentAmount(), - binding.descriptionEdittext.text.toString(), - getRecurringTypeFromSpinnerSelection(binding.expenseTypeSpinner.selectedItemPosition), - ) - } - } - } - - /** - * Set revenue text view layout - */ - private fun setExpenseTypeTextViewLayout(isRevenue: Boolean, isEditing: Boolean) { - if (isRevenue) { - binding.expenseTypeTv.setText(R.string.income) - binding.expenseTypeTv.setTextColor(ContextCompat.getColor(this, R.color.budget_green)) - - binding.expenseTypeSwitch.isChecked = true - - if( isEditing ) { - setTitle(R.string.title_activity_recurring_income_edit) - } else { - setTitle(R.string.title_activity_recurring_income_add) - } - } else { - binding.expenseTypeTv.setText(R.string.payment) - binding.expenseTypeTv.setTextColor(ContextCompat.getColor(this, R.color.budget_red)) - - binding.expenseTypeSwitch.isChecked = false - - if( isEditing ) { - setTitle(R.string.title_activity_recurring_expense_edit) - } else { - setTitle(R.string.title_activity_recurring_expense_add) - } - } - } - - /** - * Set up text field focus behavior - */ - private fun setUpTextFields(description: String?, amount: Double?, type: RecurringExpenseType?) { - binding.amountInputlayout.hint = resources.getString(R.string.amount, parameters.getUserCurrency().symbol) - - val recurringTypesString = arrayOf( - getString(R.string.recurring_interval_daily), - getString(R.string.recurring_interval_weekly), - getString(R.string.recurring_interval_bi_weekly), - getString(R.string.recurring_interval_ter_weekly), - getString(R.string.recurring_interval_four_weekly), - getString(R.string.recurring_interval_monthly), - getString(R.string.recurring_interval_bi_monthly), - getString(R.string.recurring_interval_ter_monthly), - getString(R.string.recurring_interval_six_monthly), - getString(R.string.recurring_interval_yearly) - ) - - val adapter = ArrayAdapter(this, R.layout.spinner_item, recurringTypesString) - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - binding.expenseTypeSpinner.adapter = adapter - - if( type != null ) { - setSpinnerSelectionFromRecurringType(type) - } else { - setSpinnerSelectionFromRecurringType(RecurringExpenseType.MONTHLY) - } - - if (description != null) { - binding.descriptionEdittext.setText(description) - binding.descriptionEdittext.setSelection(binding.descriptionEdittext.text?.length ?: 0) // Put focus at the end of the text - } - - binding.amountEdittext.preventUnsupportedInputForDecimals() - - if (amount != null) { - binding.amountEdittext.setText(CurrencyHelper.getFormattedAmountValue(abs(amount))) - } - } - - /** - * Get the recurring expense type associated with the spinner selection - * - * @param spinnerSelectedItem index of the spinner selection - * @return the corresponding expense type - */ - private fun getRecurringTypeFromSpinnerSelection(spinnerSelectedItem: Int): RecurringExpenseType { - when (spinnerSelectedItem) { - 0 -> return RecurringExpenseType.DAILY - 1-> return RecurringExpenseType.WEEKLY - 2 -> return RecurringExpenseType.BI_WEEKLY - 3 -> return RecurringExpenseType.TER_WEEKLY - 4 -> return RecurringExpenseType.FOUR_WEEKLY - 5 -> return RecurringExpenseType.MONTHLY - 6 -> return RecurringExpenseType.BI_MONTHLY - 7 -> return RecurringExpenseType.TER_MONTHLY - 8 -> return RecurringExpenseType.SIX_MONTHLY - 9 -> return RecurringExpenseType.YEARLY - } - - throw IllegalStateException("getRecurringTypeFromSpinnerSelection unable to get value for $spinnerSelectedItem") - } - - private fun setSpinnerSelectionFromRecurringType(type: RecurringExpenseType) { - val selectionIndex = when (type) { - RecurringExpenseType.DAILY -> 0 - RecurringExpenseType.WEEKLY -> 1 - RecurringExpenseType.BI_WEEKLY -> 2 - RecurringExpenseType.TER_WEEKLY -> 3 - RecurringExpenseType.FOUR_WEEKLY -> 4 - RecurringExpenseType.MONTHLY -> 5 - RecurringExpenseType.BI_MONTHLY -> 6 - RecurringExpenseType.TER_MONTHLY -> 7 - RecurringExpenseType.SIX_MONTHLY -> 8 - RecurringExpenseType.YEARLY -> 9 - } - - binding.expenseTypeSpinner.setSelection(selectionIndex, false) - } - - /** - * Set up the date button - */ - private fun setUpDateButton(date: LocalDate) { - val formatter = DateTimeFormatter.ofPattern(resources.getString(R.string.add_expense_date_format), Locale.getDefault()) - binding.dateButton.text = formatter.format(date) - - binding.dateButton.setOnClickListener { - val fragment = DatePickerDialogFragment(date) { _, year, monthOfYear, dayOfMonth -> - viewModel.onDateChanged(LocalDate.of(year, monthOfYear + 1, dayOfMonth)) - } - - fragment.show(supportFragmentManager, "datePicker") - } - } - - private fun getCurrentAmount(): Double { - return java.lang.Double.parseDouble(binding.amountEdittext.text.toString()) - } - - companion object { - const val ARG_EXPENSE = "expense" - const val ARG_START_DATE = "dateStart" - - fun newIntent( - context: Context, - editedExpense: Expense?, - startDate: LocalDate, - ): Intent { - return Intent(context, RecurringExpenseEditActivity::class.java).apply { - putExtra(ARG_START_DATE, startDate.toEpochDay()) - if (editedExpense != null) { - putExtra(ARG_EXPENSE, editedExpense) - } - } - } - } -} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/recurringexpenseadd/RecurringExpenseEditView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/recurringexpenseadd/RecurringExpenseEditView.kt new file mode 100644 index 00000000..e211cebf --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/recurringexpenseadd/RecurringExpenseEditView.kt @@ -0,0 +1,576 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.recurringexpenseadd + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.compose.AppWithTopAppBarScaffold +import com.benoitletondor.easybudgetapp.compose.BackButtonBehavior +import com.benoitletondor.easybudgetapp.compose.components.ExpenseEditTextField +import com.benoitletondor.easybudgetapp.helper.CurrencyHelper +import com.benoitletondor.easybudgetapp.helper.SerializedExpense +import com.benoitletondor.easybudgetapp.helper.launchCollect +import com.benoitletondor.easybudgetapp.helper.sanitizeFromUnsupportedInputForDecimals +import com.benoitletondor.easybudgetapp.helper.stringRepresentation +import com.benoitletondor.easybudgetapp.model.Expense +import com.benoitletondor.easybudgetapp.model.RecurringExpenseType +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.Serializable +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Currency +import java.util.Date +import java.util.Locale +import kotlin.math.abs + +@Serializable +data class RecurringExpenseAddDestination(val dateEpochDay: Long) { + constructor( + date: LocalDate, + ) : this(date.toEpochDay()) +} + +@Serializable +data class RecurringExpenseEditDestination(val dateEpochDay: Long, val editedExpense: SerializedExpense) { + constructor( + date: LocalDate, + editedExpense: Expense, + ) : this(date.toEpochDay(), SerializedExpense(editedExpense)) +} + +@Composable +fun RecurringExpenseEditView( + viewModel: RecurringExpenseEditViewModel, + navigateUp: () -> Unit, + finish: () -> Unit, +) { + RecurringExpenseEditView( + stateFlow = viewModel.stateFlow, + eventFlow = viewModel.eventFlow, + userCurrencyFlow = viewModel.userCurrencyFlow, + navigateUp = navigateUp, + finish = finish, + onTitleUpdate = viewModel::onTitleChanged, + onAmountUpdate = viewModel::onAmountChanged, + onSaveButtonClicked = viewModel::onSave, + onIsRevenueChanged = viewModel::onExpenseRevenueValueChanged, + onDateClicked = viewModel::onDateClicked, + onDateSelected = viewModel::onDateSelected, + onAddExpenseBeforeInitDateConfirmed = viewModel::onAddExpenseBeforeInitDateConfirmed, + onAddExpenseBeforeInitDateCancelled = viewModel::onAddExpenseBeforeInitDateCancelled, + onEditRecurringIntervalClicked = viewModel::onEditRecurringIntervalClicked, + onRecurringIntervalSelected = viewModel::onRecurringExpenseTypeChanged, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RecurringExpenseEditView( + stateFlow: StateFlow, + eventFlow: Flow, + userCurrencyFlow: StateFlow, + navigateUp: () -> Unit, + finish: () -> Unit, + onTitleUpdate: (String) -> Unit, + onAmountUpdate: (String) -> Unit, + onSaveButtonClicked: () -> Unit, + onIsRevenueChanged: (Boolean) -> Unit, + onDateClicked: () -> Unit, + onDateSelected: (Long?) -> Unit, + onAddExpenseBeforeInitDateConfirmed: () -> Unit, + onAddExpenseBeforeInitDateCancelled: () -> Unit, + onEditRecurringIntervalClicked: () -> Unit, + onRecurringIntervalSelected: (RecurringExpenseType) -> Unit, +) { + val context = LocalContext.current + var showDatePickerWithDate by remember { mutableStateOf(null) } + var amountValueError: String? by remember { mutableStateOf(null) } + var titleValueError: String? by remember { mutableStateOf(null) } + var shouldShowRecurringIntervalPicker by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = "eventsListener") { + launchCollect(eventFlow) { event -> + when (event) { + RecurringExpenseEditViewModel.Event.ExpenseAddBeforeInitDateError -> MaterialAlertDialogBuilder(context) + .setTitle(R.string.expense_add_before_init_date_dialog_title) + .setMessage(R.string.expense_add_before_init_date_dialog_description) + .setPositiveButton(R.string.expense_add_before_init_date_dialog_positive_cta) { _, _ -> + onAddExpenseBeforeInitDateConfirmed() + } + .setNegativeButton(R.string.expense_add_before_init_date_dialog_negative_cta) { _, _ -> + onAddExpenseBeforeInitDateCancelled() + } + .show() + RecurringExpenseEditViewModel.Event.Finish -> finish() + RecurringExpenseEditViewModel.Event.UnableToLoadDB -> MaterialAlertDialogBuilder(context) + .setTitle(R.string.expense_edit_unable_to_load_db_error_title) + .setMessage(R.string.expense_edit_unable_to_load_db_error_message) + .setPositiveButton(R.string.expense_edit_unable_to_load_db_error_cta) { _, _ -> + finish() + } + .setCancelable(false) + .show() + is RecurringExpenseEditViewModel.Event.ErrorPersistingExpense -> MaterialAlertDialogBuilder(context) + .setTitle(R.string.expense_edit_error_saving_title) + .setMessage(R.string.expense_edit_error_saving_message) + .setPositiveButton(R.string.ok) { dialog, _ -> dialog.dismiss() } + .show() + RecurringExpenseEditViewModel.Event.EmptyTitleError -> titleValueError = context.getString(R.string.no_description_error) + is RecurringExpenseEditViewModel.Event.ShowDatePicker -> showDatePickerWithDate = event.date + RecurringExpenseEditViewModel.Event.EmptyAmountError -> amountValueError = context.getString( + R.string.no_amount_error) + RecurringExpenseEditViewModel.Event.ShowRecurringIntervalPicker -> shouldShowRecurringIntervalPicker = true + } + } + } + + val state by stateFlow.collectAsState() + + BackHandler(enabled = state.isSaving) { + /* No-op to disable back press while saving */ + } + + AppWithTopAppBarScaffold( + title = stringResource(if (state.isEditing) { + if (state.isRevenue) { R.string.title_activity_recurring_income_edit } else { R.string.title_activity_recurring_expense_edit } + } else { + if (state.isRevenue) { R.string.title_activity_recurring_income_add } else { R.string.title_activity_recurring_expense_add } + }), + backButtonBehavior = BackButtonBehavior.NavigateBack( + onBackButtonPressed = navigateUp, + ), + content = { contentPadding -> + val titleFocusRequester = remember { FocusRequester() } + val amountFocusRequester = remember { FocusRequester() } + LaunchedEffect(key1 = "focusRequester") { + titleFocusRequester.requestFocus() + } + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = colorResource(R.color.action_bar_background)) + .padding(horizontal = 26.dp) + .padding(top = 10.dp, bottom = 30.dp), + ) { + var descriptionTextFieldValue by remember { mutableStateOf( + TextFieldValue( + text = state.expense.title, + selection = TextRange(index = state.expense.title.length), + ) + ) } + + ExpenseEditTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(titleFocusRequester), + value = descriptionTextFieldValue, + onValueChange = { + descriptionTextFieldValue = it + titleValueError = null + onTitleUpdate(it.text) + }, + isError = titleValueError != null, + label = if (titleValueError != null ) "${stringResource(R.string.description)}: $titleValueError" else stringResource( + R.string.description), + keyboardActions = KeyboardActions( + onNext = { + titleFocusRequester.freeFocus() + amountFocusRequester.requestFocus() + }, + ), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Text, + capitalization = KeyboardCapitalization.Sentences, + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + val currency by userCurrencyFlow.collectAsState() + + var currentAmountTextFieldValue by remember { mutableStateOf( + TextFieldValue( + text = if (state.expense.amount == 0.0) "" else CurrencyHelper.getFormattedAmountValue( + abs(state.expense.amount) + ), + selection = TextRange(index = if (state.expense.amount == 0.0) 0 else CurrencyHelper.getFormattedAmountValue( + abs(state.expense.amount) + ).length), + ) + ) } + + ExpenseEditTextField( + modifier = Modifier + .weight(0.5f) + .focusRequester(amountFocusRequester), + value = currentAmountTextFieldValue, + onValueChange = { newValue -> + val newText = newValue.text.sanitizeFromUnsupportedInputForDecimals(supportsNegativeValue = false) + + currentAmountTextFieldValue = TextFieldValue( + text = newText, + selection = newValue.selection, + ) + + amountValueError = null + onAmountUpdate(newText) + }, + isError = amountValueError != null, + label = if (amountValueError != null) "${stringResource(R.string.amount, currency.symbol)}: $amountValueError" else stringResource( + R.string.amount, currency.symbol), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + ) + ) + + Spacer(modifier = Modifier.width(20.dp)) + + Column( + modifier = Modifier + .weight(0.5f) + .padding(top = 5.dp), + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.recurring_expense_interval), + color = colorResource(R.color.expense_edit_title_text_color_dark), + fontSize = 15.sp, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onEditRecurringIntervalClicked) + .padding(top = 2.dp), + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = state.recurringExpenseType.stringRepresentation(context), + color = colorResource(R.color.expense_edit_field_accent_color_dark), + fontSize = 17.sp, + ) + + Spacer(modifier = Modifier.height(5.dp)) + + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = colorResource(R.color.expense_edit_field_accent_color_dark), + thickness = 1.dp, + ) + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) + } + + FloatingActionButton( + modifier = Modifier + .align(Alignment.End) + .offset(y = (-30).dp, x = (-26).dp), + onClick = onSaveButtonClicked, + containerColor = colorResource(R.color.secondary), + contentColor = colorResource(R.color.white), + ) { + Icon( + painter = painterResource(R.drawable.ic_save_white_24dp), + contentDescription = stringResource(R.string.fab_add_expense), + ) + } + + Row( + modifier = Modifier + .offset(y = (-20).dp) + .fillMaxWidth() + .padding(horizontal = 26.dp), + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(R.string.type), + color = colorResource(R.color.expense_edit_title_text_color), + fontSize = 14.sp, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + checked = state.isRevenue, + onCheckedChange = onIsRevenueChanged, + colors = SwitchDefaults.colors( + checkedTrackColor = colorResource(R.color.add_expense_expense_thumb_background_color), + checkedThumbColor = colorResource(R.color.budget_green), + uncheckedThumbColor = colorResource(R.color.budget_red), + uncheckedTrackColor = colorResource(R.color.add_expense_expense_thumb_background_color), + uncheckedBorderColor = Color.Transparent, + checkedBorderColor = Color.Transparent, + ), + thumbContent = { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + ) + } + ) + + Spacer(modifier = Modifier.width(5.dp)) + + Text( + text = stringResource(if(state.isRevenue) R.string.income else R.string.payment), + color = colorResource(if(state.isRevenue) R.color.budget_green else R.color.budget_red), + fontSize = 14.sp, + ) + } + + + } + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(R.string.first_occurence), + color = colorResource(R.color.expense_edit_title_text_color), + fontSize = 14.sp, + ) + + Spacer(modifier = Modifier.height(5.dp)) + + val dateFormatter = remember { + DateTimeFormatter.ofPattern(context.getString(R.string.add_expense_date_format), Locale.getDefault()) + } + val dateString = remember(state.expense.date) { + dateFormatter.format(state.expense.date) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onDateClicked) + .padding(top = 3.dp), + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = dateString, + textAlign = TextAlign.Center, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = colorResource(R.color.expense_edit_field_accent_color), + thickness = 1.dp, + ) + } + } + } + } + } + + val datePickerDate = showDatePickerWithDate + if (datePickerDate != null) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = Date.from(datePickerDate.atStartOfDay().atZone( + ZoneId.of("UTC")).toInstant()).time + ) + + DatePickerDialog( + onDismissRequest = { showDatePickerWithDate = null }, + confirmButton = { + Button( + modifier = Modifier.padding(end = 16.dp, bottom = 10.dp), + onClick = { + onDateSelected(datePickerState.selectedDateMillis) + showDatePickerWithDate = null + } + ) { + Text(text = stringResource(R.string.ok)) + } + }, + content = { + DatePicker(state = datePickerState) + }, + ) + } + + if (shouldShowRecurringIntervalPicker) { + RecurringIntervalPickerDialog( + onDismissRequest = { shouldShowRecurringIntervalPicker = false }, + onIntervalSelected = { interval -> + onRecurringIntervalSelected(interval) + shouldShowRecurringIntervalPicker = false + }, + ) + } + + if (state.isSaving) { + Dialog(onDismissRequest = { /* No-op */ }) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.window_background), + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator() + + Spacer(modifier = Modifier.width(10.dp)) + + Text( + modifier = Modifier.weight(1f), + text = stringResource(if (state.isRevenue) R.string.recurring_income_add_loading_message else R.string.recurring_expense_add_loading_message), + fontSize = 18.sp, + ) + } + } + } + } + }, + ) +} + +@Composable +private fun RecurringIntervalPickerDialog( + onDismissRequest: () -> Unit, + onIntervalSelected: (RecurringExpenseType) -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + ) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.window_background), + ), + ) { + Column( + modifier = Modifier + .padding(bottom = 10.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 20.dp, bottom = 10.dp), + text = stringResource(R.string.recurring_expense_interval), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + textAlign = TextAlign.Center, + ) + + RecurringExpenseType.entries.forEach { recurringExpenseType -> + Text( + modifier = Modifier + .fillMaxWidth() + .background(color = colorResource(R.color.window_background)) + .clickable { onIntervalSelected(recurringExpenseType) } + .padding(horizontal = 26.dp, vertical = 16.dp), + text = recurringExpenseType.stringRepresentation(LocalContext.current), + ) + } + } + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/recurringexpenseadd/RecurringExpenseEditViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/recurringexpenseadd/RecurringExpenseEditViewModel.kt index 4a97fbc3..25f2aa27 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/recurringexpenseadd/RecurringExpenseEditViewModel.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/recurringexpenseadd/RecurringExpenseEditViewModel.kt @@ -16,7 +16,7 @@ package com.benoitletondor.easybudgetapp.view.recurringexpenseadd -import androidx.lifecycle.SavedStateHandle +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.benoitletondor.easybudgetapp.helper.Logger @@ -24,66 +24,98 @@ import com.benoitletondor.easybudgetapp.model.Expense import com.benoitletondor.easybudgetapp.model.RecurringExpenseType import com.benoitletondor.easybudgetapp.db.DB import com.benoitletondor.easybudgetapp.helper.MutableLiveFlow +import com.benoitletondor.easybudgetapp.helper.combine +import com.benoitletondor.easybudgetapp.helper.watchUserCurrency +import com.benoitletondor.easybudgetapp.injection.CurrentDBProvider import kotlinx.coroutines.launch import com.benoitletondor.easybudgetapp.model.RecurringExpense import com.benoitletondor.easybudgetapp.parameters.Parameters import com.benoitletondor.easybudgetapp.parameters.getInitDate -import com.benoitletondor.easybudgetapp.view.main.account.AccountViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext +import java.time.Instant import java.time.LocalDate -import javax.inject.Inject +import java.time.ZoneId +import kotlin.math.abs -@HiltViewModel -class RecurringExpenseEditViewModel @Inject constructor( +@HiltViewModel(assistedFactory = RecurringExpenseEditViewModelFactory::class) +class RecurringExpenseEditViewModel @AssistedInject constructor( private val parameters: Parameters, - savedStateHandle: SavedStateHandle, + currentDBProvider: CurrentDBProvider, + @Assisted private val editedExpense: Expense?, + @Assisted date: LocalDate, ) : ViewModel() { - /** - * Expense that is being edited (will be null if it's a new one) - */ - private val editedExpense: Expense? = savedStateHandle[RecurringExpenseEditActivity.ARG_EXPENSE] - - private val expenseDateMutableStateFlow = MutableStateFlow(LocalDate.ofEpochDay( - savedStateHandle[RecurringExpenseEditActivity.ARG_START_DATE] ?: throw IllegalStateException("No ARG_START_DATE arg"))) - val expenseDateFlow: Flow = expenseDateMutableStateFlow - - private val editTypeMutableStateFlow = MutableStateFlow(ExpenseEditType( - editedExpense?.isRevenue() ?: false, - editedExpense != null - )) - val editTypeFlow: Flow = editTypeMutableStateFlow - - val existingExpenseData = editedExpense?.let { expense -> - ExistingExpenseData(expense.title, expense.amount, expense.associatedRecurringExpense!!.recurringExpense.type) - } - - private val savingStateMutableStateFlow: MutableStateFlow = MutableStateFlow(SavingState.Idle) - val savingStateFlow: Flow = savingStateMutableStateFlow - - private val expenseAddBeforeInitDateErrorMutableFlow = MutableLiveFlow() - val expenseAddBeforeInitDateEventFlow: Flow = expenseAddBeforeInitDateErrorMutableFlow - - private val unableToLoadDBEventMutableFlow = MutableLiveFlow() - val unableToLoadDBEventFlow: Flow = unableToLoadDBEventMutableFlow - - private val finishMutableFlow = MutableLiveFlow() - val finishFlow: Flow = finishMutableFlow - - private val errorMutableFlow = MutableLiveFlow() - val errorFlow: Flow = errorMutableFlow + private val dateMutableStateFlow = MutableStateFlow(date) + private val amountMutableStateFlow = MutableStateFlow(editedExpense?.amount ?: 0.0) + private val isRevenueMutableStateFlow = MutableStateFlow(editedExpense?.isRevenue() ?: false) + private val titleMutableStateFlow = MutableStateFlow(editedExpense?.title ?: "") + private val recurringExpenseTypeMutableStateFlow = MutableStateFlow(editedExpense?.associatedRecurringExpense?.recurringExpense?.type ?: RecurringExpenseType.MONTHLY) + private val isSavingMutableStateFlow = MutableStateFlow(false) + + val stateFlow: StateFlow = combine( + dateMutableStateFlow, + amountMutableStateFlow, + isRevenueMutableStateFlow, + titleMutableStateFlow, + recurringExpenseTypeMutableStateFlow, + isSavingMutableStateFlow, + ) { date, amount, isRevenue, title, recurringExpenseType, isSaving -> + val expense = Expense( + id = editedExpense?.id, + title = title, + amount = if (isRevenue) -abs(amount) else abs(amount), + date = date, + checked = editedExpense?.checked ?: false, + associatedRecurringExpense = editedExpense?.associatedRecurringExpense, + ) + + return@combine State( + isEditing = editedExpense != null, + isSaving = isSaving, + recurringExpenseType = recurringExpenseType, + isRevenue = isRevenue, + expense = expense, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, State( + isEditing = editedExpense != null, + isRevenue = editedExpense?.isRevenue() ?: false, + isSaving = false, + recurringExpenseType = recurringExpenseTypeMutableStateFlow.value, + expense = Expense( + id = editedExpense?.id, + title = titleMutableStateFlow.value, + amount = if (isRevenueMutableStateFlow.value) -abs(amountMutableStateFlow.value) else abs( + amountMutableStateFlow.value + ), + date = dateMutableStateFlow.value, + checked = editedExpense?.checked ?: false, + associatedRecurringExpense = editedExpense?.associatedRecurringExpense, + ) + ) + ) + + val userCurrencyFlow = parameters.watchUserCurrency() + + private val eventMutableFlow = MutableLiveFlow() + val eventFlow: Flow = eventMutableFlow private lateinit var db: DB init { - val currentDb = AccountViewModel.getCurrentDB() + val currentDb = currentDBProvider.activeDB if (currentDb == null) { viewModelScope.launch { - unableToLoadDBEventMutableFlow.emit(Unit) + eventMutableFlow.emit(Event.UnableToLoadDB) } } else { db = currentDb @@ -91,31 +123,76 @@ class RecurringExpenseEditViewModel @Inject constructor( } fun onExpenseRevenueValueChanged(isRevenue: Boolean) { - editTypeMutableStateFlow.value = ExpenseEditType(isRevenue, editedExpense != null) + isRevenueMutableStateFlow.value = isRevenue } - fun onSave(value: Double, description: String, recurringExpenseType: RecurringExpenseType) { - val isRevenue = editTypeMutableStateFlow.value.isRevenue - val date = expenseDateMutableStateFlow.value + fun onDateSelected(utcTimestamp: Long?) { + if (utcTimestamp != null) { + dateMutableStateFlow.value = Instant.ofEpochMilli(utcTimestamp) + .atZone(ZoneId.of("UTC")) + .toLocalDate() + } + } - val dateOfInstallation = parameters.getInitDate() ?: LocalDate.now() + fun onDateClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowDatePicker(dateMutableStateFlow.value)) + } + } + + fun onRecurringExpenseTypeChanged(recurringExpenseType: RecurringExpenseType) { + recurringExpenseTypeMutableStateFlow.value = recurringExpenseType + } + + fun onAmountChanged(amount: String) { + amountMutableStateFlow.value = amount.toDoubleOrNull() ?: 0.0 + } + + fun onTitleChanged(title: String) { + titleMutableStateFlow.value = title + } + + fun onEditRecurringIntervalClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowRecurringIntervalPicker) + } + } + + fun onSave() { + var isInError = false + if (titleMutableStateFlow.value.isEmpty()) { + isInError = true + viewModelScope.launch { + eventMutableFlow.emit(Event.EmptyTitleError) + } + } + + if (amountMutableStateFlow.value == 0.0) { + isInError = true + viewModelScope.launch { + eventMutableFlow.emit(Event.EmptyAmountError) + } + } + if (isInError) { + return + } + + val date = dateMutableStateFlow.value + val dateOfInstallation = parameters.getInitDate() ?: LocalDate.now() if( date.isBefore(dateOfInstallation) ) { viewModelScope.launch { - expenseAddBeforeInitDateErrorMutableFlow.emit(Unit) + eventMutableFlow.emit(Event.ExpenseAddBeforeInitDateError) } return } - doSaveExpense(value, description, recurringExpenseType, editedExpense, isRevenue, date) + doSaveExpense(stateFlow.value.expense, stateFlow.value.recurringExpenseType) } - fun onAddExpenseBeforeInitDateConfirmed(value: Double, description: String, recurringExpenseType: RecurringExpenseType) { - val isRevenue = editTypeMutableStateFlow.value.isRevenue - val date = expenseDateMutableStateFlow.value - - doSaveExpense(value, description, recurringExpenseType, editedExpense, isRevenue, date) + fun onAddExpenseBeforeInitDateConfirmed() { + doSaveExpense(stateFlow.value.expense, stateFlow.value.recurringExpenseType) } fun onAddExpenseBeforeInitDateCancelled() { @@ -123,74 +200,77 @@ class RecurringExpenseEditViewModel @Inject constructor( } private fun doSaveExpense( - value: Double, - description: String, + expense: Expense, recurringExpenseType: RecurringExpenseType, - editedExpense: Expense?, - isRevenue: Boolean, - date: LocalDate, ) { - savingStateMutableStateFlow.value = SavingState.Saving(isRevenue) + isSavingMutableStateFlow.value = true viewModelScope.launch { try { - val inserted = withContext(Dispatchers.Default) { - if( editedExpense == null ) { - try { - db.persistRecurringExpense(RecurringExpense(description, if (isRevenue) -value else value, date, recurringExpenseType)) - return@withContext true - } catch (e: Exception) { - if (e is CancellationException) throw e - - Logger.error("Error while inserting recurring expense into DB", e) - return@withContext false - } - - } else { - try { - val recurringExpense = editedExpense.associatedRecurringExpense!!.recurringExpense - db.updateRecurringExpenseAfterDate( - recurringExpense.copy( - modified = true, - type = recurringExpenseType, - recurringDate = date, - title = description, - amount = if (isRevenue) -value else value - ), - editedExpense.date, + withContext(Dispatchers.IO) { + if (editedExpense == null) { + db.persistRecurringExpense( + RecurringExpense( + expense.title, + expense.amount, + expense.date, + recurringExpenseType ) - - return@withContext true - } catch (e: Exception) { - if (e is CancellationException) throw e - - Logger.error("Error while editing recurring expense into DB", e) - return@withContext false - } + ) + return@withContext true + } else { + val recurringExpense = + editedExpense.associatedRecurringExpense!!.recurringExpense + db.updateRecurringExpenseAfterDate( + recurringExpense.copy( + modified = true, + type = recurringExpenseType, + recurringDate = expense.date, + title = expense.title, + amount = expense.amount, + ), + editedExpense.date, + ) } } - if( inserted ) { - finishMutableFlow.emit(Unit) - } else { - errorMutableFlow.emit(Unit) - } + eventMutableFlow.emit(Event.Finish) + } catch (e: Exception) { + if (e is CancellationException) throw e + + Logger.error("Error persisting recurring expense", e) + eventMutableFlow.emit(Event.ErrorPersistingExpense(e)) } finally { - savingStateMutableStateFlow.value = SavingState.Idle + isSavingMutableStateFlow.value = false } } } - fun onDateChanged(date: LocalDate) { - expenseDateMutableStateFlow.value = date + sealed class Event { + data object Finish : Event() + data object UnableToLoadDB : Event() + data object EmptyTitleError : Event() + data object EmptyAmountError : Event() + data object ExpenseAddBeforeInitDateError : Event() + data class ErrorPersistingExpense(val error: Throwable) : Event() + data class ShowDatePicker(val date: LocalDate) : Event() + data object ShowRecurringIntervalPicker : Event() } - sealed class SavingState { - data object Idle : SavingState() - data class Saving(val isRevenue: Boolean): SavingState() - } + @Immutable + data class State( + val isEditing: Boolean, + val isSaving: Boolean, + val recurringExpenseType: RecurringExpenseType, + val expense: Expense, + val isRevenue: Boolean, + ) } -data class ExpenseEditType(val isRevenue: Boolean, val editing: Boolean) - -data class ExistingExpenseData(val title: String, val amount: Double, val type: RecurringExpenseType) \ No newline at end of file +@AssistedFactory +interface RecurringExpenseEditViewModelFactory { + fun create( + editedExpense: Expense?, + date: LocalDate, + ): RecurringExpenseEditViewModel +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/MonthlyReportFragment.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/MonthlyReportFragment.kt deleted file mode 100644 index 3836ed95..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/MonthlyReportFragment.kt +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.report - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ProgressBar -import android.widget.TextView -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.helper.CurrencyHelper -import com.benoitletondor.easybudgetapp.helper.launchCollect -import com.benoitletondor.easybudgetapp.helper.viewLifecycleScope -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import java.time.YearMonth -import javax.inject.Inject - -private const val ARG_MONTH = "arg_month" - -/** - * Fragment that displays monthly report for a given month - * - * @author Benoit LETONDOR - */ -@AndroidEntryPoint -class MonthlyReportFragment : Fragment() { - /** - * The first day of the month - */ - private lateinit var month: YearMonth - - private val viewModel: MonthlyReportViewModel by viewModels() - @Inject lateinit var parameters: Parameters - -// ----------------------------------> - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - lifecycleScope.launchCollect(viewModel.unableToLoadDBEventFlow) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.monthly_report_unable_to_load_db_error_title) - .setMessage(R.string.monthly_report_unable_to_load_db_error_message) - .setPositiveButton(R.string.monthly_report_unable_to_load_db_error_cta) { _, _ -> - requireActivity().finish() - } - .setCancelable(false) - .show() - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - month = requireArguments().getSerializable(ARG_MONTH) as YearMonth - - // Inflate the layout for this fragment - val v = inflater.inflate(R.layout.fragment_monthly_report, container, false) - - val progressBar = v.findViewById(R.id.monthly_report_fragment_progress_bar) - val content = v.findViewById(R.id.monthly_report_fragment_content) - val recyclerView = v.findViewById(R.id.monthly_report_fragment_recycler_view) - val emptyState = v.findViewById(R.id.monthly_report_fragment_empty_state) - val revenuesAmountTextView = v.findViewById(R.id.monthly_report_fragment_revenues_total_tv) - val expensesAmountTextView = v.findViewById(R.id.monthly_report_fragment_expenses_total_tv) - val balanceTextView = v.findViewById(R.id.monthly_report_fragment_balance_tv) - - viewLifecycleScope.launchCollect(viewModel.stateFlow) { state -> - when(state) { - MonthlyReportViewModel.MonthlyReportState.Empty -> { - progressBar.visibility = View.GONE - content.visibility = View.VISIBLE - - recyclerView.visibility = View.GONE - emptyState.visibility = View.VISIBLE - - revenuesAmountTextView.text = CurrencyHelper.getFormattedCurrencyString(parameters, 0.0) - expensesAmountTextView.text = CurrencyHelper.getFormattedCurrencyString(parameters, 0.0) - balanceTextView.text = CurrencyHelper.getFormattedCurrencyString(parameters, 0.0) - balanceTextView.setTextColor(ContextCompat.getColor(balanceTextView.context, R.color.budget_green)) - } - is MonthlyReportViewModel.MonthlyReportState.Loaded -> { - progressBar.visibility = View.GONE - content.visibility = View.VISIBLE - - configureRecyclerView(recyclerView, MonthlyReportRecyclerViewAdapter(state.expenses, state.revenues, parameters)) - - revenuesAmountTextView.text = CurrencyHelper.getFormattedCurrencyString(parameters, state.revenuesAmount) - expensesAmountTextView.text = CurrencyHelper.getFormattedCurrencyString(parameters, state.expensesAmount) - - val balance = state.revenuesAmount - state.expensesAmount - balanceTextView.text = CurrencyHelper.getFormattedCurrencyString(parameters, balance) - balanceTextView.setTextColor(ContextCompat.getColor(balanceTextView.context, if (balance >= 0) R.color.budget_green else R.color.budget_red)) - } - MonthlyReportViewModel.MonthlyReportState.Loading -> { - progressBar.visibility = View.VISIBLE - content.visibility = View.GONE - } - } - } - - viewModel.loadDataForMonth(month) - - return v - } - - /** - * Configure recycler view LayoutManager & adapter - */ - private fun configureRecyclerView(recyclerView: RecyclerView, adapter: MonthlyReportRecyclerViewAdapter) { - recyclerView.layoutManager = LinearLayoutManager(activity) - recyclerView.adapter = adapter - } - - companion object { - fun newInstance(month: YearMonth): MonthlyReportFragment = MonthlyReportFragment().apply { - arguments = Bundle().apply { - putSerializable(ARG_MONTH, month) - } - } - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/MonthlyReportRecyclerViewAdapter.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/MonthlyReportRecyclerViewAdapter.kt deleted file mode 100644 index 1b9b41ee..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/MonthlyReportRecyclerViewAdapter.kt +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.report - -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView - -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.helper.CurrencyHelper -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.benoitletondor.easybudgetapp.model.Expense -import com.benoitletondor.easybudgetapp.model.RecurringExpenseType - -import java.time.format.DateTimeFormatter -import java.util.Locale - -/** - * Type of cell for an [Expense] - */ -private const val EXPENSE_VIEW_TYPE = 1 -/** - * Type of cell for a header - */ -private const val HEADER_VIEW_TYPE = 2 - -/** - * The adapter for the [MonthlyReportFragment] recycler view. - * - * @author Benoit LETONDOR - */ -class MonthlyReportRecyclerViewAdapter(private val expenses: List, - private val revenues: List, - private val parameters: Parameters) : RecyclerView.Adapter() { - - /** - * Formatter to get day number for each date - */ - private val dayFormatter = DateTimeFormatter.ofPattern("dd", Locale.getDefault()) - -// ---------------------------------------> - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - if (HEADER_VIEW_TYPE == viewType) { - val v = LayoutInflater.from(parent.context).inflate(R.layout.recyclerview_monthly_report_header_cell, parent, false) - return HeaderViewHolder(v) - } - - val v = LayoutInflater.from(parent.context).inflate(R.layout.recyclerview_monthly_report_expense_cell, parent, false) - return ExpenseViewHolder(v) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - if (holder is HeaderViewHolder) { - val isRevenuesHeader = isRevenuesHeader(position) - - holder.headerTitle.setText(if (isRevenuesHeader) R.string.revenues else R.string.expenses) - holder.view.setBackgroundColor(ContextCompat.getColor(holder.view.context, if (isRevenuesHeader) R.color.budget_green else R.color.budget_red)) - } else { - val viewHolder = holder as ExpenseViewHolder - val expense = getExpense(position) - - viewHolder.expenseTitleTextView.text = expense.title - viewHolder.expenseAmountTextView.text = CurrencyHelper.getFormattedCurrencyString(parameters, -expense.amount) - viewHolder.expenseAmountTextView.setTextColor(ContextCompat.getColor(viewHolder.view.context, if (expense.isRevenue()) R.color.budget_green else R.color.budget_red)) - viewHolder.monthlyIndicator.visibility = if (expense.isRecurring()) View.VISIBLE else View.GONE - - if (expense.isRecurring()) { - when (expense.associatedRecurringExpense!!.recurringExpense.type) { - RecurringExpenseType.DAILY -> viewHolder.recurringExpenseTypeTextView.text = viewHolder.view.context.getString(R.string.daily) - RecurringExpenseType.WEEKLY -> viewHolder.recurringExpenseTypeTextView.text = viewHolder.view.context.getString(R.string.weekly) - RecurringExpenseType.BI_WEEKLY -> viewHolder.recurringExpenseTypeTextView.text = viewHolder.view.context.getString(R.string.bi_weekly) - RecurringExpenseType.TER_WEEKLY -> viewHolder.recurringExpenseTypeTextView.text = viewHolder.view.context.getString(R.string.ter_weekly) - RecurringExpenseType.FOUR_WEEKLY -> viewHolder.recurringExpenseTypeTextView.text = viewHolder.view.context.getString(R.string.four_weekly) - RecurringExpenseType.MONTHLY -> viewHolder.recurringExpenseTypeTextView.text = viewHolder.view.context.getString(R.string.monthly) - RecurringExpenseType.BI_MONTHLY -> viewHolder.recurringExpenseTypeTextView.text = viewHolder.view.context.getString(R.string.bi_monthly) - RecurringExpenseType.TER_MONTHLY -> viewHolder.recurringExpenseTypeTextView.text = viewHolder.view.context.getString(R.string.ter_monthly) - RecurringExpenseType.SIX_MONTHLY -> viewHolder.recurringExpenseTypeTextView.text = viewHolder.view.context.getString(R.string.six_monthly) - RecurringExpenseType.YEARLY -> viewHolder.recurringExpenseTypeTextView.text = viewHolder.view.context.getString(R.string.yearly) - } - } - - viewHolder.dateTextView.text = dayFormatter.format(expense.date) - } - } - - override fun getItemCount() = (if (expenses.isEmpty()) 0 else expenses.size + 1) + if (revenues.isEmpty()) 0 else revenues.size + 1 - - override fun getItemViewType(position: Int) = if (isHeader(position)) HEADER_VIEW_TYPE else EXPENSE_VIEW_TYPE - - /** - * Get the expense for the given position - * - * @param position the position - * @return the expense for that position - */ - private fun getExpense(position: Int): Expense { - if (revenues.isNotEmpty() && position - 1 < revenues.size) { - return revenues[position - 1] - } - - val expensesHeaderDelta = 1 + if (revenues.isEmpty()) 0 else 1 - return expenses[position - expensesHeaderDelta - revenues.size] - } - - /** - * Is the given position an header cell - * - * @param position the position - * @return true if it's an header, false otherwise - */ - private fun isHeader(position: Int): Boolean { - return isExpensesHeader(position) || isRevenuesHeader(position) - } - - /** - * Is the given position the expense header cell - * - * @param position the position - * @return true if it's the expense header, false otherwise - */ - private fun isExpensesHeader(position: Int): Boolean { - return expenses.isNotEmpty() && position == revenues.size + if (revenues.isEmpty()) 0 else 1 - } - - /** - * Is the given position the revenue header cell - * - * @param position the position - * @return true if it's the revenue header, false otherwise - */ - private fun isRevenuesHeader(position: Int): Boolean { - return revenues.isNotEmpty() && position == 0 - } - -// ---------------------------------------> - - class ExpenseViewHolder internal constructor(internal val view: View) : RecyclerView.ViewHolder(view) { - internal val expenseTitleTextView: TextView = view.findViewById(R.id.expense_title) - internal val expenseAmountTextView: TextView = view.findViewById(R.id.expense_amount) - internal val monthlyIndicator: ViewGroup = view.findViewById(R.id.recurring_indicator) - internal val dateTextView: TextView = view.findViewById(R.id.date_tv) - internal val recurringExpenseTypeTextView: TextView = view.findViewById(R.id.recurring_expense_type) - } - - class HeaderViewHolder internal constructor(internal val view: View) : RecyclerView.ViewHolder(view) { - internal val headerTitle: TextView = view.findViewById(R.id.monthly_recycler_view_header_tv) - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/MonthlyReportViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/MonthlyReportViewModel.kt deleted file mode 100644 index 0ed9c236..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/MonthlyReportViewModel.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.report - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.benoitletondor.easybudgetapp.model.Expense -import com.benoitletondor.easybudgetapp.db.DB -import com.benoitletondor.easybudgetapp.helper.MutableLiveFlow -import com.benoitletondor.easybudgetapp.view.main.account.AccountViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.time.YearMonth -import javax.inject.Inject - -@HiltViewModel -class MonthlyReportViewModel @Inject constructor() : ViewModel() { - private val stateMutableFlow = MutableStateFlow(MonthlyReportState.Loading) - val stateFlow: Flow = stateMutableFlow - - private val unableToLoadDBEventMutableFlow = MutableLiveFlow() - val unableToLoadDBEventFlow: Flow = unableToLoadDBEventMutableFlow - - private lateinit var db: DB - - init { - val currentDb = AccountViewModel.getCurrentDB() - if (currentDb == null) { - viewModelScope.launch { - unableToLoadDBEventMutableFlow.emit(Unit) - } - } else { - db = currentDb - } - } - - fun loadDataForMonth(month: YearMonth) { - if (!::db.isInitialized) { - return - } - - viewModelScope.launch { - val expensesForMonth = withContext(Dispatchers.Default) { - db.getExpensesForMonth(month) - } - if( expensesForMonth.isEmpty() ) { - stateMutableFlow.emit(MonthlyReportState.Empty) - return@launch - } - - val expenses = mutableListOf() - val revenues = mutableListOf() - var revenuesAmount = 0.0 - var expensesAmount = 0.0 - - withContext(Dispatchers.Default) { - for(expense in expensesForMonth) { - if( expense.isRevenue() ) { - revenues.add(expense) - revenuesAmount -= expense.amount - } else { - expenses.add(expense) - expensesAmount += expense.amount - } - } - } - - stateMutableFlow.emit(MonthlyReportState.Loaded(expenses, revenues, expensesAmount, revenuesAmount)) - } - } - - sealed class MonthlyReportState { - data object Loading : MonthlyReportState() - data object Empty: MonthlyReportState() - class Loaded(val expenses: List, val revenues: List, val expensesAmount: Double, val revenuesAmount: Double) : MonthlyReportState() - } -} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/base/MonthlyReportBaseActivity.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/base/MonthlyReportBaseActivity.kt deleted file mode 100644 index 196c1ee8..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/base/MonthlyReportBaseActivity.kt +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.report.base - -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import androidx.activity.viewModels -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentPagerAdapter -import androidx.lifecycle.lifecycleScope -import androidx.viewpager.widget.ViewPager -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.databinding.ActivityMonthlyReportBinding -import com.benoitletondor.easybudgetapp.helper.BaseActivity -import com.benoitletondor.easybudgetapp.helper.getMonthTitle -import com.benoitletondor.easybudgetapp.helper.launchCollect -import com.benoitletondor.easybudgetapp.helper.removeButtonBorder -import com.benoitletondor.easybudgetapp.view.report.MonthlyReportFragment -import com.benoitletondor.easybudgetapp.view.report.export.ExportReportActivity -import dagger.hilt.android.AndroidEntryPoint -import java.time.YearMonth - -/** - * Activity that displays monthly report - * - * @author Benoit LETONDOR - */ -@AndroidEntryPoint -class MonthlyReportBaseActivity : BaseActivity(), ViewPager.OnPageChangeListener { - - private val viewModel: MonthlyReportBaseViewModel by viewModels() - - private var ignoreNextPageSelectedEvent: Boolean = false - - override fun createBinding(): ActivityMonthlyReportBinding = ActivityMonthlyReportBinding.inflate(layoutInflater) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - binding.monthlyReportPreviousMonthButton.text = "<" - binding.monthlyReportNextMonthButton.text = ">" - - binding.monthlyReportPreviousMonthButton.setOnClickListener { - viewModel.onPreviousMonthButtonClicked() - } - - binding.monthlyReportNextMonthButton.setOnClickListener { - viewModel.onNextMonthButtonClicked() - } - - binding.monthlyReportPreviousMonthButton.removeButtonBorder() - binding.monthlyReportNextMonthButton.removeButtonBorder() - - var loadedMonths: List = emptyList() - lifecycleScope.launchCollect(viewModel.stateFlow) { state -> - when(state) { - is MonthlyReportBaseViewModel.State.Loaded -> { - binding.monthlyReportProgressBar.visibility = View.GONE - binding.monthlyReportContent.visibility = View.VISIBLE - - if (state.months != loadedMonths) { - loadedMonths = state.months - configureViewPager(state.months) - } - - if( !ignoreNextPageSelectedEvent ) { - binding.monthlyReportViewPager.setCurrentItem(state.selectedPosition.position, true) - } - - ignoreNextPageSelectedEvent = false - - binding.monthlyReportMonthTitleTv.text = state.selectedPosition.month.getMonthTitle(this) - - // Last and first available month - val isFirstMonth = state.selectedPosition.position == 0 - - binding.monthlyReportNextMonthButton.isEnabled = !state.selectedPosition.latest - binding.monthlyReportPreviousMonthButton.isEnabled = !isFirstMonth - } - MonthlyReportBaseViewModel.State.Loading -> { - binding.monthlyReportProgressBar.visibility = View.VISIBLE - binding.monthlyReportContent.visibility = View.GONE - } - } - } - - lifecycleScope.launchCollect(viewModel.eventFlow) { event -> - when(event) { - is MonthlyReportBaseViewModel.Event.OpenExport -> startActivity(ExportReportActivity.createIntent(this, event.month)) - MonthlyReportBaseViewModel.Event.RefreshMenu -> invalidateOptionsMenu() - } - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - val inflater: MenuInflater = menuInflater - inflater.inflate(R.menu.menu_monthly_report, menu) - return true - } - - override fun onPrepareOptionsMenu(menu: Menu?): Boolean { - if (menu == null) { - return false - } - - if (!viewModel.shouldShowExportButton()) { - menu.removeItem(R.id.action_export) - } - - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - - if (id == android.R.id.home) { - finish() - return true - } else if (id == R.id.action_export) { - viewModel.onExportButtonClicked() - return true - } - - return super.onOptionsItemSelected(item) - } - - /** - * Configure the [.pager] adapter and listener. - */ - private fun configureViewPager(dates: List) { - binding.monthlyReportViewPager.removeOnPageChangeListener(this) - - binding.monthlyReportViewPager.offscreenPageLimit = 0 - binding.monthlyReportViewPager.adapter = object : FragmentPagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { - override fun getItem(position: Int): Fragment { - return MonthlyReportFragment.newInstance(dates[position]) - } - - override fun getCount(): Int { - return dates.size - } - } - binding.monthlyReportViewPager.addOnPageChangeListener(this) - } - -// ------------------------------------------> - - override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {} - - override fun onPageSelected(position: Int) { - ignoreNextPageSelectedEvent = true - - viewModel.onPageSelected(position) - } - - override fun onPageScrollStateChanged(state: Int) {} - - companion object { - /** - * Extra to add the the launch intent to specify that user comes from the notification (used to - * show not the current month but the last one) - */ - const val FROM_NOTIFICATION_EXTRA = "fromNotif" - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/base/MonthlyReportBaseViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/base/MonthlyReportBaseViewModel.kt deleted file mode 100644 index a2082c11..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/base/MonthlyReportBaseViewModel.kt +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.report.base - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.benoitletondor.easybudgetapp.helper.Logger -import com.benoitletondor.easybudgetapp.helper.MutableLiveFlow -import com.benoitletondor.easybudgetapp.helper.getListOfMonthsAvailableForUser -import com.benoitletondor.easybudgetapp.helper.launchCollect -import com.benoitletondor.easybudgetapp.iab.Iab -import com.benoitletondor.easybudgetapp.iab.PremiumCheckStatus -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.benoitletondor.easybudgetapp.view.report.base.MonthlyReportBaseActivity.Companion.FROM_NOTIFICATION_EXTRA -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.time.YearMonth -import javax.inject.Inject - -@HiltViewModel -class MonthlyReportBaseViewModel @Inject constructor( - private val parameters: Parameters, - private val iab: Iab, - savedStateHandle: SavedStateHandle, -) : ViewModel() { - private val fromNotification = savedStateHandle.get(FROM_NOTIFICATION_EXTRA) ?: false - - private var isUserPro = false - - private val eventMutableFlow = MutableLiveFlow() - val eventFlow: Flow = eventMutableFlow - - private val stateMutableFlow = MutableStateFlow(State.Loading) - val stateFlow: Flow = stateMutableFlow - - init { - viewModelScope.launch { - stateMutableFlow.value = State.Loading - - val months = withContext(Dispatchers.IO) { - return@withContext parameters.getListOfMonthsAvailableForUser() - } - - var currentMonthPosition = months.indexOf(YearMonth.now()) - if (currentMonthPosition == -1) { - Logger.error("Error while getting current month position, returned -1", IllegalStateException("Current month not found in list of available months")) - currentMonthPosition = months.size - 1 - } - - val selectedPosition = if( !fromNotification || months.size == 1) { - MonthlyReportSelectedPosition(currentMonthPosition, months[currentMonthPosition], currentMonthPosition == months.size - 1) - } else { - MonthlyReportSelectedPosition(currentMonthPosition - 1, months[currentMonthPosition - 1], false) - } - - stateMutableFlow.value = State.Loaded(months, selectedPosition) - } - - viewModelScope.launch { - iab.iabStatusFlow - .map { it == PremiumCheckStatus.PRO_SUBSCRIBED } - .distinctUntilChanged() - .collect { isPro -> - isUserPro = isPro - eventMutableFlow.emit(Event.RefreshMenu) - } - } - } - - fun shouldShowExportButton(): Boolean = isUserPro - - fun onPreviousMonthButtonClicked() { - val loadedState = stateMutableFlow.value as? State.Loaded ?: return - - val position = loadedState.selectedPosition.position - if (position > 0) { - stateMutableFlow.value = loadedState.copy( - selectedPosition = MonthlyReportSelectedPosition(position - 1, loadedState.months[position - 1], false) - ) - } - } - - fun onNextMonthButtonClicked() { - val loadedState = stateMutableFlow.value as? State.Loaded ?: return - - val position = loadedState.selectedPosition.position - if ( position < loadedState.months.size - 1 ) { - stateMutableFlow.value = loadedState.copy( - selectedPosition = MonthlyReportSelectedPosition(position + 1, loadedState.months[position + 1], loadedState.months.size == position + 2) - ) - } - } - - fun onPageSelected(position: Int) { - val loadedState = stateMutableFlow.value as? State.Loaded ?: return - - stateMutableFlow.value = loadedState.copy( - selectedPosition = MonthlyReportSelectedPosition(position, loadedState.months[position], loadedState.months.size == position + 1) - ) - } - - fun onExportButtonClicked() { - val loadedState = stateMutableFlow.value as? State.Loaded ?: return - - val selectedMonth = loadedState.selectedPosition.month - viewModelScope.launch { - eventMutableFlow.emit(Event.OpenExport(selectedMonth)) - } - } - - sealed class State { - data object Loading : State() - data class Loaded(val months: List, val selectedPosition: MonthlyReportSelectedPosition) : State() - } - - sealed class Event { - data object RefreshMenu : Event() - data class OpenExport(val month: YearMonth) : Event() - } -} - -data class MonthlyReportSelectedPosition(val position: Int, val month: YearMonth, val latest: Boolean) \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/export/ExportReportActivity.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/export/ExportReportActivity.kt deleted file mode 100644 index a767afa2..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/report/export/ExportReportActivity.kt +++ /dev/null @@ -1,348 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.benoitletondor.easybudgetapp.view.report.export - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.MenuItem -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.content.FileProvider -import com.benoitletondor.easybudgetapp.BuildConfig -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.databinding.ActivityMonthlyReportExportBinding -import com.benoitletondor.easybudgetapp.db.DB -import com.benoitletondor.easybudgetapp.helper.BaseActivity -import com.benoitletondor.easybudgetapp.helper.CurrencyHelper -import com.benoitletondor.easybudgetapp.helper.Logger -import com.benoitletondor.easybudgetapp.helper.toFormattedString -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.benoitletondor.easybudgetapp.theme.AppTheme -import com.benoitletondor.easybudgetapp.view.main.account.AccountViewModel -import com.benoitletondor.easybudgetapp.view.report.export.ExportReportActivity.Companion.REQUEST_CODE_SHARE_CSV -import com.benoitletondor.easybudgetapp.view.report.export.ExportReportActivity.Companion.tempFileName -import com.github.doyaaaaaken.kotlincsv.dsl.csvWriter -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import java.lang.IllegalArgumentException -import java.time.YearMonth -import java.time.format.DateTimeFormatter -import javax.inject.Inject - -@AndroidEntryPoint -class ExportReportActivity: BaseActivity() { - private lateinit var month: YearMonth - @Inject - lateinit var parameters: Parameters - - override fun createBinding(): ActivityMonthlyReportExportBinding = ActivityMonthlyReportExportBinding.inflate(layoutInflater) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - month = intent.getSerializableExtra(EXTRA_MONTH) as? YearMonth ?: throw IllegalArgumentException("Missing month extra") - val currentDb = AccountViewModel.getCurrentDB() - if (currentDb == null) { - Logger.error("Unable to get current DB in ExportReportActivity", Exception("No DB found")) - finish() - return - } - - setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - binding.exportComposeView.setContent { - AppTheme { - ExportReportScreen( - db = currentDb, - parameters = parameters, - month = month, - ) - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - - if (id == android.R.id.home) { - finish() - return true - } - - return super.onOptionsItemSelected(item) - } - - @Deprecated("This method has been deprecated in favor of using the Activity Result API\n which brings increased type safety via an {@link ActivityResultContract} and the prebuilt\n contracts for common intents available in\n {@link androidx.activity.result.contract.ActivityResultContracts}, provides hooks for\n testing, and allow receiving results in separate, testable classes independent from your\n activity. Use\n {@link #registerForActivityResult(ActivityResultContract, ActivityResultCallback)}\n with the appropriate {@link ActivityResultContract} and handling the result in the\n {@link ActivityResultCallback#onActivityResult(Object) callback}.") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - - if (requestCode == REQUEST_CODE_SHARE_CSV && resultCode == Activity.RESULT_OK) { - finish() - } - } - - override fun onDestroy() { - val tempFile = File(cacheDir, tempFileName(month)) - if (tempFile.exists()) { - Logger.debug("Deleting temporary file: ${tempFile.path}") - tempFile.delete() - } - - super.onDestroy() - } - - companion object { - private const val EXTRA_MONTH = "extra_month" - const val REQUEST_CODE_SHARE_CSV = 488230 - - fun createIntent(context: Context, month: YearMonth): Intent = Intent(context, ExportReportActivity::class.java).apply { - putExtra(EXTRA_MONTH, month) - } - - fun tempFileName(month: YearMonth): String = "export_${month.year}_${month.monthValue}.csv" - } -} - -private sealed class State { - data object Loading : State() - data class Loaded(val csvFile: File) : State() - data class Error(val exception: Exception) : State() -} - -@Composable -private fun ExportReportScreen( - db: DB, - parameters: Parameters, - month: YearMonth, -) { - var retryLoadingState by remember { mutableIntStateOf(0) } - var state by remember { mutableStateOf(State.Loading) } - - val context = LocalContext.current - val activity = context as Activity - - LaunchedEffect(month, retryLoadingState) { - state = State.Loading - state = try { - val csvFile = withContext(Dispatchers.IO) { - val expenses = db.getExpensesForMonth(month) - val file = File(context.cacheDir, tempFileName(month)) - val dateFormatter = DateTimeFormatter.ISO_DATE - - Logger.debug("Creating temporary file: ${file.path}") - csvWriter().openAsync(file) { - writeRow(listOf( - context.getString(R.string.monthly_report_data_date_row), - context.getString(R.string.monthly_report_data_title_row), - context.getString(R.string.monthly_report_data_amount_row), - context.getString(R.string.monthly_report_data_recurring_row), - context.getString(R.string.monthly_report_data_checked_row), - )) - for(expense in expenses) { - writeRow(listOf( - dateFormatter.format(expense.date), - expense.title, - CurrencyHelper.getFormattedCurrencyString(parameters, -expense.amount), - expense.associatedRecurringExpense?.recurringExpense?.type?.toFormattedString(context) ?: "", - if (expense.checked) "X" else "", - )) - } - } - - return@withContext file - } - - State.Loaded(csvFile) - } catch (e: Exception) { - if (e is CancellationException) throw e - - State.Error(e) - } - } - - when(val currentState = state) { - is State.Error -> ErrorView( - exception = currentState.exception, - onRetryButtonClicked = { retryLoadingState++ }, - ) - is State.Loaded -> LoadedView( - onDownloadButtonClicked = { - try { - val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", currentState.csvFile) - - val shareIntent = Intent().apply { - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // Allow external app to open the file - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_STREAM, uri) - type = "text/csv" - } - - activity.startActivityForResult(Intent.createChooser(shareIntent, null), REQUEST_CODE_SHARE_CSV) - } catch (e: Exception) { - Logger.error("Error while opening CSV file", e) - MaterialAlertDialogBuilder(context) - .setTitle(R.string.monthly_report_data_open_error_title) - .setMessage(R.string.monthly_report_data_open_error_description) - .setPositiveButton(R.string.ok, null) - .show() - } - } - ) - State.Loading -> LoadingView() - } -} - -@Composable -private fun LoadingView() { - Box( - modifier = Modifier.fillMaxSize(), - ) { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - ) - } -} - -@Composable -private fun ErrorView( - exception: Exception, - onRetryButtonClicked: () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 20.dp, vertical = 16.dp), - verticalArrangement = Arrangement.Center, - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.monthly_report_data_loading_error_title), - textAlign = TextAlign.Center, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.monthly_report_data_loading_error_description, exception.localizedMessage ?: "No error message"), - fontSize = 16.sp, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - Button( - modifier = Modifier.align(Alignment.CenterHorizontally), - onClick = onRetryButtonClicked, - ) { - Text(stringResource(R.string.monthly_report_data_loading_error_cta)) - } - } -} - -@Composable -private fun LoadedView( - onDownloadButtonClicked: () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 20.dp, vertical = 16.dp), - verticalArrangement = Arrangement.Center, - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.monthly_report_export_data_loaded_title), - textAlign = TextAlign.Center, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - Button( - modifier = Modifier.align(Alignment.CenterHorizontally), - onClick = onDownloadButtonClicked, - ) { - Text( - text = stringResource(id = R.string.monthly_report_export_data_loaded_cta), - ) - } - } -} - -@Composable -@Preview(name = "Loading preview", showSystemUi = true) -private fun LoadingPreview() { - AppTheme { - LoadingView() - } -} - -@Composable -@Preview(name = "Error preview", showSystemUi = true) -private fun ErrorPreview() { - AppTheme { - ErrorView( - exception = IllegalArgumentException("An error occurred"), - onRetryButtonClicked = {}, - ) - } -} - -@Composable -@Preview(name = "Success preview", showSystemUi = true) -private fun SuccessPreview() { - AppTheme { - LoadedView( - onDownloadButtonClicked = {}, - ) - } -} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyFragment.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyFragment.kt deleted file mode 100644 index 48ceff7e..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyFragment.kt +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.selectcurrency - -import android.app.Dialog -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope - -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.helper.launchCollect -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -/** - * Fragment that contains UI for user to chose its currency.

- * You should listen to the [.CURRENCY_SELECTED_INTENT] intent to get notified when - * the selected currency has changed. The newly selected currency ISO code is available in - * the [.CURRENCY_ISO_EXTRA] string extra.

- *

- * NB: The setUserCurrency method is automaticaly called by the fragment on selection, y - * ou don't have to do it yourself. - * - * @author Benoit LETONDOR - */ -@AndroidEntryPoint -class SelectCurrencyFragment : DialogFragment() { - private val viewModel: SelectCurrencyViewModel by viewModels() - - @Inject lateinit var parameters: Parameters - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - if (showsDialog) { - return null - } - - // Inflate the layout for this fragment - val v = inflater.inflate(R.layout.fragment_select_currency, container, false) - - setupRecyclerView(v) - - return v - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - // Inflate the layout for this fragment - val v = LayoutInflater.from(activity).inflate(R.layout.fragment_select_currency, null, false) - setupRecyclerView(v) - - // Put some padding between title and content - v.setPadding(0, resources.getDimensionPixelSize(R.dimen.select_currency_dialog_padding_top), 0, 0) - - val builder = MaterialAlertDialogBuilder(requireActivity()) - - builder.setView(v) - builder.setTitle(R.string.setting_category_currency_change_dialog_title) - - return builder.create() - } - -// -------------------------------------> - - /** - * Setup the recycler view - * - * @param v inflated view - */ - private fun setupRecyclerView(v: View) { - val recyclerView = v.findViewById(R.id.select_currency_recycler_view) - recyclerView.layoutManager = LinearLayoutManager(v.context) - - lifecycleScope.launchCollect(viewModel.stateFlow) { state -> - when(state) { - is SelectCurrencyViewModel.State.Loaded -> { - val adapter = SelectCurrencyRecyclerViewAdapter(state.mainCurrencies, state.otherCurrencies, parameters) - recyclerView.adapter = adapter - - if( adapter.selectedCurrencyPosition() > 1 ) { - recyclerView.scrollToPosition(adapter.selectedCurrencyPosition()-1) - } - } - SelectCurrencyViewModel.State.Loading -> Unit // TODO: loading state - } - } - } - - companion object { - /** - * Action of the intent broadcasted when selected currency has changed - */ - const val CURRENCY_SELECTED_INTENT = "currency.selected" - /** - * Key to retrieve the newly selected currency ISO code in Intent extras - */ - const val CURRENCY_ISO_EXTRA = "currency.iso.key" - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyRecyclerViewAdapter.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyRecyclerViewAdapter.kt deleted file mode 100644 index 2426722e..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyRecyclerViewAdapter.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.selectcurrency - -import android.content.Intent -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.recyclerview.widget.RecyclerView -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView - -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.helper.CurrencyHelper -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.benoitletondor.easybudgetapp.helper.getUserCurrency -import com.benoitletondor.easybudgetapp.helper.setUserCurrency - -import java.util.Currency - -/** - * View adapter for the Recycler view of the [SelectCurrencyFragment] - * - * @author Benoit LETONDOR - */ -class SelectCurrencyRecyclerViewAdapter(private val mainCurrencies: List, - private val secondaryCurrencies: List, - private val parameters: Parameters) : RecyclerView.Adapter() { - - /** - * Get the position of the selected currency - */ - fun selectedCurrencyPosition(): Int { - val currency = parameters.getUserCurrency() - - return if (mainCurrencies.contains(currency)) mainCurrencies.indexOf(currency) else secondaryCurrencies.indexOf(currency) + 1 + mainCurrencies.size - } - -// ----------------------------------------> - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return if (viewType == TYPE_MAIN_CURRENCY || viewType == TYPE_SECONDARY_CURRENCY) { - val v = LayoutInflater.from(parent.context).inflate(R.layout.recycleview_currency_cell, parent, false) - ViewHolder(v, viewType) - } else { - val v = LayoutInflater.from(parent.context).inflate(R.layout.recycleview_currency_separator_cell, parent, false) - ViewHolder(v, viewType, true) - } - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - if (!holder.separator) { - val currency = if (holder.type == TYPE_MAIN_CURRENCY) mainCurrencies[position] else secondaryCurrencies[position - 1 - mainCurrencies.size] - - val userCurrency = parameters.getUserCurrency() == currency - - holder.selectedIndicator?.visibility = if (userCurrency) View.VISIBLE else View.INVISIBLE - holder.currencyTitle?.text = CurrencyHelper.getCurrencyDisplayName(currency) - holder.v.setOnClickListener { v -> - // Set the currency - parameters.setUserCurrency(currency) - // Reload date to change the checkmark - notifyDataSetChanged() - - // Broadcast the intent - val intent = Intent(SelectCurrencyFragment.CURRENCY_SELECTED_INTENT) - intent.putExtra(SelectCurrencyFragment.CURRENCY_ISO_EXTRA, currency.currencyCode) - - LocalBroadcastManager.getInstance(v.context).sendBroadcast(intent) - } - } - } - - override fun getItemCount(): Int { - return mainCurrencies.size + 1 + secondaryCurrencies.size - } - - override fun getItemViewType(position: Int): Int { - return when { - position < mainCurrencies.size -> TYPE_MAIN_CURRENCY - position == mainCurrencies.size -> TYPE_SEPARATOR - else -> TYPE_SECONDARY_CURRENCY - } - } - -// -------------------------------------------> - - class ViewHolder(val v: View, val type: Int, val separator: Boolean = false) : RecyclerView.ViewHolder(v) { - var currencyTitle: TextView? = null - var selectedIndicator: ImageView? = null - - init { - if (!separator) { - currencyTitle = v.findViewById(R.id.currency_cell_title_tv) - selectedIndicator = v.findViewById(R.id.currency_cell_selected_indicator_iv) - } - } - } - - companion object { - private const val TYPE_MAIN_CURRENCY = 0 - private const val TYPE_SEPARATOR = 1 - private const val TYPE_SECONDARY_CURRENCY = 2 - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyView.kt new file mode 100644 index 00000000..aa36dce9 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyView.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.selectcurrency + +import android.annotation.SuppressLint +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.hilt.navigation.compose.hiltViewModel +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.compose.components.LoadingView +import com.benoitletondor.easybudgetapp.helper.CurrencyHelper +import kotlinx.coroutines.flow.StateFlow +import java.util.Currency + +@Composable +fun SelectCurrencyDialog( + contentPadding: PaddingValues, + onDismissRequest: () -> Unit, +) { + Dialog(onDismissRequest = onDismissRequest) { + Card( + modifier = Modifier + .padding(contentPadding) + .fillMaxWidth() + .fillMaxHeight(0.7f), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.window_background), + ), + ) { + SelectCurrencyView( + modifier = Modifier.fillMaxSize(), + onCurrencySelected = onDismissRequest, + ) + } + } +} + +@Composable +fun SelectCurrencyView( + modifier: Modifier = Modifier, + viewModel: SelectCurrencyViewModel = hiltViewModel(), + onCurrencySelected: () -> Unit = {}, +) { + SelectCurrencyView( + modifier = modifier, + stateFlow = viewModel.stateFlow, + onCurrencySelected = { currency -> + viewModel.onCurrencySelected(currency) + onCurrencySelected() + }, + ) +} + +@Composable +private fun SelectCurrencyView( + modifier: Modifier, + stateFlow: StateFlow, + onCurrencySelected: (Currency) -> Unit, +) { + val state by stateFlow.collectAsState() + + when (val currentState = state) { + is SelectCurrencyViewModel.State.Loading -> LoadingView( + modifier = modifier, + ) + is SelectCurrencyViewModel.State.Loaded -> CurrenciesView( + modifier = modifier, + state = currentState, + onCurrencySelected = onCurrencySelected, + ) + } +} + +@SuppressLint("PrivateResource") +@Composable +private fun CurrenciesView( + modifier: Modifier, + state: SelectCurrencyViewModel.State.Loaded, + onCurrencySelected: (Currency) -> Unit, +) { + LazyColumn( + modifier = modifier, + ) { + items( + count = state.mainCurrencies.size + state.otherCurrencies.size, + ) { index -> + val currencyItem = if (index < state.mainCurrencies.size) { + state.mainCurrencies[index] + } else { + state.otherCurrencies[index - state.mainCurrencies.size] + } + + Column( + modifier = Modifier.fillMaxWidth(), + ){ + if (index == state.mainCurrencies.size) { + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = colorResource(R.color.divider), + thickness = 3.dp, + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onCurrencySelected(currencyItem.currency) + } + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.ic_check_green_24dp), + contentDescription = stringResource(androidx.compose.ui.R.string.selected), + modifier = Modifier.alpha(if (currencyItem.isSelected) 1f else 0f) + ) + + Spacer(modifier = Modifier.padding(6.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = CurrencyHelper.getCurrencyDisplayName(currencyItem.currency), + ) + } + + if (index < state.mainCurrencies.size - 1 + state.otherCurrencies.size) { + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = colorResource(R.color.divider), + thickness = 1.dp, + ) + } + } + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyViewModel.kt index 4a6787e4..4783c2be 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyViewModel.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/selectcurrency/SelectCurrencyViewModel.kt @@ -16,22 +16,28 @@ package com.benoitletondor.easybudgetapp.view.selectcurrency +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.benoitletondor.easybudgetapp.helper.CurrencyHelper +import com.benoitletondor.easybudgetapp.helper.getUserCurrency +import com.benoitletondor.easybudgetapp.helper.setUserCurrency +import com.benoitletondor.easybudgetapp.parameters.Parameters import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.* import javax.inject.Inject @HiltViewModel -class SelectCurrencyViewModel @Inject constructor() : ViewModel() { +class SelectCurrencyViewModel @Inject constructor( + private val parameters: Parameters, +) : ViewModel() { private val stateMutableFlow = MutableStateFlow(State.Loading) - val stateFlow: Flow = stateMutableFlow + val stateFlow: StateFlow = stateMutableFlow init { viewModelScope.launch { @@ -39,12 +45,53 @@ class SelectCurrencyViewModel @Inject constructor() : ViewModel() { Pair(CurrencyHelper.getMainAvailableCurrencies(), CurrencyHelper.getOtherAvailableCurrencies()) } - stateMutableFlow.emit(State.Loaded(mainCurrencies, otherCurrencies)) + val userCurrency = parameters.getUserCurrency() + + stateMutableFlow.emit(State.Loaded( + mainCurrencies = mainCurrencies.map { + CurrencyItem( + currency = it, + isSelected = it == userCurrency, + ) + }, + otherCurrencies = otherCurrencies.map { + CurrencyItem( + currency = it, + isSelected = it == userCurrency, + ) + }, + )) + } + } + + fun onCurrencySelected(currency: Currency) { + viewModelScope.launch { + parameters.setUserCurrency(currency) + + val currentState = stateFlow.value as? State.Loaded ?: return@launch + stateMutableFlow.emit(currentState.copy( + mainCurrencies = currentState.mainCurrencies.map { + it.copy(isSelected = it.currency == currency) + }, + otherCurrencies = currentState.otherCurrencies.map { + it.copy(isSelected = it.currency == currency) + }, + )) } } sealed class State { data object Loading : State() - data class Loaded(val mainCurrencies: List, val otherCurrencies: List) : State() + @Immutable + data class Loaded( + val mainCurrencies: List, + val otherCurrencies: List, + ) : State() } + + @Immutable + data class CurrencyItem( + val currency: Currency, + val isSelected: Boolean, + ) } \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/PreferencesFragment.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/PreferencesFragment.kt deleted file mode 100644 index 23f82e30..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/PreferencesFragment.kt +++ /dev/null @@ -1,693 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.settings - -import android.Manifest -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageManager -import android.content.res.Configuration -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.view.View -import android.view.WindowManager -import android.widget.EditText -import android.widget.Toast -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.preference.CheckBoxPreference -import androidx.preference.ListPreference -import androidx.preference.Preference -import androidx.preference.PreferenceCategory -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.SwitchPreferenceCompat -import com.benoitletondor.easybudgetapp.BuildConfig -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.helper.AppTheme -import com.benoitletondor.easybudgetapp.helper.CurrencyHelper -import com.benoitletondor.easybudgetapp.helper.Logger -import com.benoitletondor.easybudgetapp.helper.getUserCurrency -import com.benoitletondor.easybudgetapp.iab.INTENT_IAB_STATUS_CHANGED -import com.benoitletondor.easybudgetapp.iab.Iab -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.benoitletondor.easybudgetapp.parameters.getFirstDayOfWeek -import com.benoitletondor.easybudgetapp.parameters.getLocalId -import com.benoitletondor.easybudgetapp.parameters.getLowMoneyWarningAmount -import com.benoitletondor.easybudgetapp.parameters.getShouldShowCheckedBalance -import com.benoitletondor.easybudgetapp.parameters.getTheme -import com.benoitletondor.easybudgetapp.parameters.isBackupEnabled -import com.benoitletondor.easybudgetapp.parameters.isUserAllowingDailyReminderPushes -import com.benoitletondor.easybudgetapp.parameters.isUserAllowingMonthlyReminderPushes -import com.benoitletondor.easybudgetapp.parameters.isUserAllowingUpdatePushes -import com.benoitletondor.easybudgetapp.parameters.setFirstDayOfWeek -import com.benoitletondor.easybudgetapp.parameters.setLowMoneyWarningAmount -import com.benoitletondor.easybudgetapp.parameters.setShouldShowCheckedBalance -import com.benoitletondor.easybudgetapp.parameters.setTheme -import com.benoitletondor.easybudgetapp.parameters.setUserAllowDailyReminderPushes -import com.benoitletondor.easybudgetapp.parameters.setUserAllowMonthlyReminderPushes -import com.benoitletondor.easybudgetapp.parameters.setUserAllowUpdatePushes -import com.benoitletondor.easybudgetapp.view.RatingPopup -import com.benoitletondor.easybudgetapp.view.main.MainActivity -import com.benoitletondor.easybudgetapp.view.main.createaccount.CreateAccountActivity -import com.benoitletondor.easybudgetapp.view.main.login.LoginActivity -import com.benoitletondor.easybudgetapp.view.premium.PremiumActivity -import com.benoitletondor.easybudgetapp.view.selectcurrency.SelectCurrencyFragment -import com.benoitletondor.easybudgetapp.view.settings.SettingsActivity.Companion.SHOW_BACKUP_INTENT_KEY -import com.benoitletondor.easybudgetapp.view.settings.SettingsActivity.Companion.USER_GONE_PREMIUM_INTENT -import com.benoitletondor.easybudgetapp.view.settings.backup.BackupSettingsActivity -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch -import java.net.URLEncoder -import java.time.DayOfWeek -import javax.inject.Inject - -/** - * Fragment to display preferences - * - * @author Benoit LETONDOR - */ -@AndroidEntryPoint -class PreferencesFragment : PreferenceFragmentCompat() { - - /** - * The dialog to select a new currency (will be null if not shown) - */ - private var selectCurrencyDialog: SelectCurrencyFragment? = null - /** - * Broadcast receiver (used for currency selection) - */ - private lateinit var receiver: BroadcastReceiver - - /** - * Category containing premium features (shown to premium users) - */ - private lateinit var premiumCategory: PreferenceCategory - /** - * Category containing ways to become premium (shown to not premium users) - */ - private lateinit var notPremiumCategory: PreferenceCategory - /** - * Launcher for notification permission request - */ - private lateinit var notificationRequestPermissionLauncher: ActivityResultLauncher - /** - * Is the premium category shown - */ - private var premiumShown = true - /** - * Is the not premium category shown - */ - private var notPremiumShown = true - - @Inject lateinit var iab: Iab - @Inject lateinit var parameters: Parameters - -// ----------------------------------------> - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.preferences, rootKey) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - notificationRequestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (!granted) { - activity?.let { - MaterialAlertDialogBuilder(it) - .setTitle(R.string.setting_notification_permission_rejected_dialog_title) - .setMessage(R.string.setting_notification_permission_rejected_dialog_description) - .setPositiveButton(R.string.setting_notification_permission_rejected_dialog_accept_cta) { dialog, _ -> - dialog.dismiss() - showNotificationPermissionIfNeeded() - } - .setNegativeButton(R.string.setting_notification_permission_rejected_dialog_not_now_cta) { dialog, _ -> - dialog.dismiss() - } - .show() - } - } - } - - /* - * Rating button - */ - findPreference(resources.getString(R.string.setting_category_rate_button_key))?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - activity?.let { activity -> - RatingPopup(activity, parameters).show(true) - } - false - } - - /* - * Start day of week - */ - val firstDayOfWeekPref = findPreference(getString(R.string.setting_category_start_day_of_week_key)) - firstDayOfWeekPref?.isChecked = parameters.getFirstDayOfWeek() == DayOfWeek.SUNDAY - firstDayOfWeekPref?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - parameters.setFirstDayOfWeek(if ((firstDayOfWeekPref?.isChecked) == true) DayOfWeek.SUNDAY else DayOfWeek.MONDAY) - true - } - - /* - * Backup - */ - findPreference(getString(R.string.setting_category_backup))?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - startActivity(Intent(context, BackupSettingsActivity::class.java)) - false - } - updateBackupPreferences() - - /* - * Bind bug report button - */ - findPreference(resources.getString(R.string.setting_category_bug_report_send_button_key))?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - val localId = parameters.getLocalId() - - val sendIntent = Intent() - sendIntent.action = Intent.ACTION_SENDTO - sendIntent.data = Uri.parse("mailto:") // only email apps should handle this - sendIntent.putExtra(Intent.EXTRA_EMAIL, arrayOf(resources.getString(R.string.bug_report_email))) - sendIntent.putExtra(Intent.EXTRA_TEXT, resources.getString(R.string.setting_category_bug_report_send_text, localId)) - sendIntent.putExtra(Intent.EXTRA_SUBJECT, resources.getString(R.string.setting_category_bug_report_send_subject)) - - val packageManager = activity?.packageManager - if (packageManager != null && sendIntent.resolveActivity(packageManager) != null) { - startActivity(sendIntent) - } else { - Toast.makeText(activity, resources.getString(R.string.setting_category_bug_report_send_error), Toast.LENGTH_SHORT).show() - } - - false - } - - /* - * Share app - */ - findPreference(resources.getString(R.string.setting_category_share_app_key))?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - try { - val sendIntent = Intent() - sendIntent.action = Intent.ACTION_SEND - sendIntent.putExtra(Intent.EXTRA_TEXT, resources.getString(R.string.app_invite_message) + "\n" + "https://play.google.com/store/apps/details?id=com.benoitletondor.easybudgetapp") - sendIntent.type = "text/plain" - startActivity(sendIntent) - } catch (e: Exception) { - Logger.error("An error occurred during sharing app activity start", e) - } - - false - } - - /* - * App version - */ - val appVersionPreference = findPreference(resources.getString(R.string.setting_category_app_version_key)) - appVersionPreference?.title = resources.getString(R.string.setting_category_app_version_title, BuildConfig.VERSION_NAME) - appVersionPreference?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - val i = Intent(Intent.ACTION_VIEW) - i.data = Uri.parse("https://twitter.com/BenoitLetondor") - activity?.startActivity(i) - - false - } - - /* - * Currency change button - */ - findPreference(resources.getString(R.string.setting_category_currency_change_button_key))?.let { currencyPreference -> - currencyPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { - selectCurrencyDialog = SelectCurrencyFragment() - selectCurrencyDialog!!.show((activity as SettingsActivity).supportFragmentManager, "SelectCurrency") - - false - } - - setCurrencyPreferenceTitle(currencyPreference) - } - - - /* - * Warning limit button - */ - findPreference(resources.getString(R.string.setting_category_limit_set_button_key))?.let { limitWarningPreference -> - limitWarningPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { - val dialogView = activity?.layoutInflater?.inflate(R.layout.dialog_set_warning_limit, null) - val limitEditText = dialogView?.findViewById(R.id.warning_limit) as EditText - limitEditText.setText(parameters.getLowMoneyWarningAmount().toString()) - limitEditText.setSelection(limitEditText.text.length) // Put focus at the end of the text - - context?.let { context -> - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(R.string.adjust_limit_warning_title) - builder.setMessage(R.string.adjust_limit_warning_message) - builder.setView(dialogView) - builder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } - builder.setPositiveButton(R.string.ok) { _, _ -> - var limitString = limitEditText.text.toString() - if (limitString.trim { it <= ' ' }.isEmpty()) { - limitString = "0" // Set a 0 value if no value is provided (will lead to an error displayed to the user) - } - - try { - val newLimit = Integer.valueOf(limitString) - - // Invalid value, alert the user - if (newLimit <= 0) { - throw IllegalArgumentException("limit should be > 0") - } - - parameters.setLowMoneyWarningAmount(newLimit) - setLimitWarningPreferenceTitle(limitWarningPreference) - LocalBroadcastManager.getInstance(context).sendBroadcast(Intent(MainActivity.INTENT_LOW_MONEY_WARNING_THRESHOLD_CHANGED)) - } catch (e: Exception) { - MaterialAlertDialogBuilder(context) - .setTitle(R.string.adjust_limit_warning_error_title) - .setMessage(resources.getString(R.string.adjust_limit_warning_error_message)) - .setPositiveButton(R.string.ok) { dialog1, _ -> dialog1.dismiss() } - .show() - } - } - - val dialog = builder.show() - - // Directly show keyboard when the dialog pops - limitEditText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> - // Check if the device doesn't have a physical keyboard - if (hasFocus && resources.configuration.keyboard == Configuration.KEYBOARD_NOKEYS) { - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - } - } - } - - false - } - - setLimitWarningPreferenceTitle(limitWarningPreference) - } - - /* - * Premium status - */ - premiumCategory = findPreference(resources.getString(R.string.setting_category_premium_key))!! - notPremiumCategory = findPreference(resources.getString(R.string.setting_category_not_premium_key))!! - refreshPremiumPreference() - - /* - * Show checked balance - */ - val showCheckedBalancePref = findPreference(resources.getString(R.string.setting_category_show_checked_balance_key)) - showCheckedBalancePref?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - parameters.setShouldShowCheckedBalance((it as CheckBoxPreference).isChecked) - - context?.let { context -> - LocalBroadcastManager.getInstance(context).sendBroadcast(Intent(MainActivity.INTENT_SHOW_CHECKED_BALANCE_CHANGED)) - } - - true - } - showCheckedBalancePref?.isChecked = parameters.getShouldShowCheckedBalance() - - /* - * Notifications - */ - val updateNotifPref = findPreference(resources.getString(R.string.setting_category_notifications_update_key)) - updateNotifPref?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - val checked = (it as CheckBoxPreference).isChecked - parameters.setUserAllowUpdatePushes(checked) - - if (checked) { - showNotificationPermissionIfNeeded() - } - - true - } - updateNotifPref?.isChecked = parameters.isUserAllowingUpdatePushes() - - /* - * Hide dev preferences if needed - */ - val devCategory = findPreference(resources.getString(R.string.setting_category_dev_key)) - if (!BuildConfig.DEV_PREFERENCES) { - devCategory?.let { preferenceScreen.removePreference(it) } - } else { - /* - * Show welcome screen button - */ - findPreference(resources.getString(R.string.setting_category_show_welcome_screen_button_key))?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - context?.let { context -> - LocalBroadcastManager.getInstance(context).sendBroadcast(Intent(MainActivity.INTENT_SHOW_WELCOME_SCREEN)) - } - - activity?.finish() - false - } - - /* - * Show premium screen - */ - findPreference(resources.getString(R.string.setting_category_dev_show_premium_key))?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - showBecomePremiumActivity() - false - } - - findPreference(getString(R.string.setting_category_dev_show_login))?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - activity?.let { activity -> - val intent = LoginActivity.newIntent(activity, shouldDismissAfterAuth = false) - activity.startActivity(intent) - } - false - } - - findPreference(getString(R.string.setting_category_dev_show_create_account))?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - activity?.let { activity -> - val intent = Intent(activity, CreateAccountActivity::class.java) - activity.startActivity(intent) - } - false - } - } - - /* - * Broadcast receiver - */ - val filter = IntentFilter(SelectCurrencyFragment.CURRENCY_SELECTED_INTENT) - filter.addAction(INTENT_IAB_STATUS_CHANGED) - filter.addAction(USER_GONE_PREMIUM_INTENT) - receiver = object : BroadcastReceiver() { - override fun onReceive(appContext: Context, intent: Intent) { - if (SelectCurrencyFragment.CURRENCY_SELECTED_INTENT == intent.action && selectCurrencyDialog != null) { - findPreference(resources.getString(R.string.setting_category_currency_change_button_key))?.let { currencyPreference -> - setCurrencyPreferenceTitle(currencyPreference) - } - - selectCurrencyDialog!!.dismiss() - selectCurrencyDialog = null - } else if (INTENT_IAB_STATUS_CHANGED == intent.action) { - try { - refreshPremiumPreference() - } catch (e: Exception) { - Logger.error("Error while receiving INTENT_IAB_STATUS_CHANGED intent", e) - } - - } else if (USER_GONE_PREMIUM_INTENT == intent.action) { - context?.let { context -> - MaterialAlertDialogBuilder(context) - .setTitle(R.string.iab_purchase_success_title) - .setMessage(R.string.iab_purchase_success_message) - .setPositiveButton(R.string.ok) { dialog, _ -> dialog.dismiss() } - .setOnDismissListener { - showNotificationPermissionIfNeeded() - } - .show() - } - - refreshPremiumPreference() - } - } - } - - context?.let { context -> - LocalBroadcastManager.getInstance(context).registerReceiver(receiver, filter) - } - - /* - * Check if we should show premium popup - */ - if (activity?.intent?.getBooleanExtra(SettingsActivity.SHOW_PREMIUM_INTENT_KEY, false) == true) { - activity?.intent?.putExtra(SettingsActivity.SHOW_PREMIUM_INTENT_KEY, false) - showBecomePremiumActivity() - } - - /* - * Check if we should show pro popup - */ - if (activity?.intent?.getBooleanExtra(SettingsActivity.SHOW_PRO_INTENT_KEY, false) == true) { - activity?.intent?.putExtra(SettingsActivity.SHOW_PRO_INTENT_KEY, false) - showBecomeProActivity() - } - - /* - * Check if we should show backup options - */ - if( activity?.intent?.getBooleanExtra(SHOW_BACKUP_INTENT_KEY, false) == true ) { - activity?.intent?.putExtra(SHOW_BACKUP_INTENT_KEY, false) - startActivity(Intent(context, BackupSettingsActivity::class.java)) - } - } - - private fun updateBackupPreferences() { - findPreference(getString(R.string.setting_category_backup))?.setSummary(if( parameters.isBackupEnabled() ) { - R.string.backup_settings_backups_activated - } else { - R.string.backup_settings_backups_deactivated - }) - } - - private fun showNotificationPermissionIfNeeded() { - if (Build.VERSION.SDK_INT < 33) { - return - } - - if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - notificationRequestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } - - override fun onResume() { - super.onResume() - - updateBackupPreferences() - } - - /** - * Set the currency preference title according to selected currency - * - * @param currencyPreference - */ - private fun setCurrencyPreferenceTitle(currencyPreference: Preference) { - currencyPreference.title = resources.getString(R.string.setting_category_currency_change_button_title, parameters.getUserCurrency().symbol) - } - - /** - * Set the limit warning preference title according to the selected limit - * - * @param limitWarningPreferenceTitle - */ - private fun setLimitWarningPreferenceTitle(limitWarningPreferenceTitle: Preference) { - limitWarningPreferenceTitle.title = resources.getString(R.string.setting_category_limit_set_button_title, CurrencyHelper.getFormattedCurrencyString(parameters, parameters.getLowMoneyWarningAmount().toDouble())) - } - - /** - * Show the right premium preference depending on the user state - */ - private fun refreshPremiumPreference() { - lifecycleScope.launch { - val isPremium = iab.isUserPremium() - val isPro = iab.isUserPro() - - if (isPremium) { - if (notPremiumShown) { - preferenceScreen.removePreference(notPremiumCategory) - notPremiumShown = false - } - - if (!premiumShown) { - preferenceScreen.addPreference(premiumCategory) - premiumShown = true - } - - // Premium/Pro preference - findPreference(resources.getString(R.string.setting_category_premium_status_key))?.let { - it.title = if (isPro) { getString(R.string.setting_category_pro_status_title)} else { getString(R.string.setting_category_premium_status_title) } - it.summary = if (isPro) { getString(R.string.setting_category_pro_status_message)} else { getString(R.string.setting_category_premium_status_message) } - it.onPreferenceClickListener = Preference.OnPreferenceClickListener { - context?.let { context -> - if (!isPro) { - showBecomeProActivity() - } else { - MaterialAlertDialogBuilder(context) - .setTitle(R.string.pro_popup_title) - .setMessage(R.string.pro_popup_message) - .setPositiveButton(R.string.ok) { dialog, _ -> dialog.dismiss() } - .show() - } - - } - - false - } - } - - // Daily reminder notif preference - val dailyNotifPref = findPreference(resources.getString(R.string.setting_category_notifications_daily_key)) - dailyNotifPref?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - val checked = (it as CheckBoxPreference).isChecked - parameters.setUserAllowDailyReminderPushes(checked) - - if (checked) { - showNotificationPermissionIfNeeded() - } - - true - } - dailyNotifPref?.isChecked = parameters.isUserAllowingDailyReminderPushes() - - // Monthly reminder for reports - val monthlyNotifPref = findPreference(resources.getString(R.string.setting_category_notifications_monthly_key)) - monthlyNotifPref?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - val checked = (it as CheckBoxPreference).isChecked - parameters.setUserAllowMonthlyReminderPushes(checked) - - if (checked) { - showNotificationPermissionIfNeeded() - } - - true - } - monthlyNotifPref?.isChecked = parameters.isUserAllowingMonthlyReminderPushes() - - // Theme - findPreference(getString(R.string.setting_category_app_theme_key))?.let { themePref -> - val currentTheme = parameters.getTheme() - - themePref.value = currentTheme.value.toString() - themePref.summary = themePref.entries[themePref.findIndexOfValue(themePref.value)] - themePref.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - themePref.summary = themePref.entries[themePref.findIndexOfValue(newValue as String)] - - val newTheme = AppTheme.entries.first { it.value == newValue.toInt() } - - parameters.setTheme(newTheme) - AppCompatDelegate.setDefaultNightMode(newTheme.toPlatformValue()) - - true - } - } - } else { - if (premiumShown) { - preferenceScreen.removePreference(premiumCategory) - premiumShown = false - } - - if (!notPremiumShown) { - preferenceScreen.addPreference(notPremiumCategory) - notPremiumShown = true - } - - // Not premium preference - findPreference(resources.getString(R.string.setting_category_not_premium_status_key))?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - showBecomePremiumActivity() - false - } - - // Redeem promo code pref - findPreference(resources.getString(R.string.setting_category_premium_redeem_key))?.onPreferenceClickListener = Preference.OnPreferenceClickListener { - activity?.let { activity -> - val dialogView = activity.layoutInflater.inflate(R.layout.dialog_redeem_voucher, null) - val voucherEditText = dialogView.findViewById(R.id.voucher) as EditText - - val builder = MaterialAlertDialogBuilder(activity) - .setTitle(R.string.voucher_redeem_dialog_title) - .setMessage(R.string.voucher_redeem_dialog_message) - .setView(dialogView) - .setPositiveButton(R.string.voucher_redeem_dialog_cta) { dialog, _ -> - dialog.dismiss() - - val promocode = voucherEditText.text.toString() - if (promocode.trim { it <= ' ' }.isEmpty()) { - MaterialAlertDialogBuilder(activity) - .setTitle(R.string.voucher_redeem_error_dialog_title) - .setMessage(R.string.voucher_redeem_error_code_invalid_dialog_message) - .setPositiveButton(R.string.ok) { dialog12, _ -> dialog12.dismiss() } - .show() - } - - if (!launchRedeemPromocodeFlow(promocode)) { - MaterialAlertDialogBuilder(activity) - .setTitle(R.string.iab_purchase_error_title) - .setMessage(resources.getString(R.string.iab_purchase_error_message, "Error redeeming promo code")) - .setPositiveButton(R.string.ok) { dialog1, _ -> dialog1.dismiss() } - .show() - } - } - .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } - - val dialog = builder.show() - - // Directly show keyboard when the dialog pops - voucherEditText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> - // Check if the device doesn't have a physical keyboard - if (hasFocus && resources.configuration.keyboard == Configuration.KEYBOARD_NOKEYS) { - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - } - } - } - - false - } - } - } - } - - private fun showBecomePremiumActivity() { - activity?.let { activity -> - val intent = PremiumActivity.createIntent(activity, shouldShowProByDefault = false) - ActivityCompat.startActivityForResult(activity, intent, SettingsActivity.PREMIUM_ACTIVITY, null) - } - } - - private fun showBecomeProActivity() { - activity?.let { activity -> - val intent = PremiumActivity.createIntent(activity, shouldShowProByDefault = true) - ActivityCompat.startActivityForResult(activity, intent, SettingsActivity.PREMIUM_ACTIVITY, null) - } - } - - /** - * Launch the redeem promocode flow - * - * @param promocode the promocode to redeem - */ - private fun launchRedeemPromocodeFlow(promocode: String): Boolean { - return try { - val url = "https://play.google.com/redeem?code=" + URLEncoder.encode(promocode, "UTF-8") - activity?.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) - true - } catch (e: Exception) { - Logger.error("Error while redeeming promocode", e) - false - } - - } - - override fun onDestroy() { - context?.let {context -> - LocalBroadcastManager.getInstance(context).unregisterReceiver(receiver) - } - - super.onDestroy() - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/SettingsActivity.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/SettingsActivity.kt deleted file mode 100644 index 710cf25d..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/SettingsActivity.kt +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.settings - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import android.view.MenuItem - -import com.benoitletondor.easybudgetapp.databinding.ActivitySettingsBinding -import com.benoitletondor.easybudgetapp.helper.BaseActivity -import com.benoitletondor.easybudgetapp.view.premium.PremiumActivity -import dagger.hilt.android.AndroidEntryPoint - -/** - * Activity that displays settings using the [PreferencesFragment] - * - * @author Benoit LETONDOR - */ -@AndroidEntryPoint -class SettingsActivity : BaseActivity() { - - override fun createBinding(): ActivitySettingsBinding = ActivitySettingsBinding.inflate(layoutInflater) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - - if (id == android.R.id.home) { - finish() - return true - } - - return super.onOptionsItemSelected(item) - } - - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - - if (requestCode == PREMIUM_ACTIVITY) { - if (resultCode == Activity.RESULT_OK) { - LocalBroadcastManager.getInstance(this).sendBroadcast(Intent(USER_GONE_PREMIUM_INTENT)) - } - } - } - - companion object { - /** - * Key to specify that the premium popup should be shown to the user - */ - const val SHOW_PREMIUM_INTENT_KEY = "showPremium" - /** - * Key to specify that the pro popup should be shown to the user - */ - const val SHOW_PRO_INTENT_KEY = "showPro" - /** - * Key to specify that the backup options should be shown to the user - */ - const val SHOW_BACKUP_INTENT_KEY = "showBackup" - /** - * Intent action broadcast when the user has successfully completed the [PremiumActivity] - */ - const val USER_GONE_PREMIUM_INTENT = "user.ispremium" - /** - * Request code used by premium activity - */ - const val PREMIUM_ACTIVITY = 20020 - } - -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/SettingsView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/SettingsView.kt new file mode 100644 index 00000000..33f7b178 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/SettingsView.kt @@ -0,0 +1,271 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.settings + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.compose.AppWithTopAppBarScaffold +import com.benoitletondor.easybudgetapp.compose.BackButtonBehavior +import com.benoitletondor.easybudgetapp.compose.components.LoadingView +import com.benoitletondor.easybudgetapp.compose.rememberPermissionStateCompat +import com.benoitletondor.easybudgetapp.helper.AppTheme +import com.benoitletondor.easybudgetapp.helper.Logger +import com.benoitletondor.easybudgetapp.helper.launchCollect +import com.benoitletondor.easybudgetapp.view.RatingPopup +import com.benoitletondor.easybudgetapp.view.selectcurrency.SelectCurrencyDialog +import com.benoitletondor.easybudgetapp.view.settings.subviews.ErrorView +import com.benoitletondor.easybudgetapp.view.settings.subviews.Settings +import com.benoitletondor.easybudgetapp.view.settings.subviews.ThemePickerDialog +import com.benoitletondor.easybudgetapp.view.settings.subviews.openRedeemCodeDialog +import com.benoitletondor.easybudgetapp.view.settings.subviews.showLowMoneyWarningAmountPickerDialog +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.Serializable +import java.time.DayOfWeek + +@Serializable +object SettingsViewDestination + +@Composable +fun SettingsView( + viewModel: SettingsViewModel = hiltViewModel(), + navigateUp: () -> Unit, + navigateToBackupSettings: () -> Unit, + navigateToPremium: () -> Unit, +) { + SettingsView( + stateFlow = viewModel.stateFlow, + eventFlow = viewModel.eventFlow, + navigateUp = navigateUp, + navigateToBackupSettings = navigateToBackupSettings, + onRetryButtonClicked = viewModel::onRetryButtonPressed, + onCurrencyChangeClicked = viewModel::onCurrencyChangeClicked, + onAdjustLowMoneyWarningAmountClicked = viewModel::onAdjustLowMoneyWarningAmountClicked, + onFirstDayOfWeekChanged = viewModel::onFirstDayOfWeekChanged, + onPremiumButtonClicked = viewModel::onPremiumButtonClicked, + onProButtonClicked = viewModel::onProButtonClicked, + onThemeClicked = viewModel::onThemeClicked, + onShowCheckedBalanceChanged = viewModel::onShowCheckedBalanceChanged, + onCloudBackupClicked = viewModel::onCloudBackupClicked, + onDailyReminderNotificationActivatedChanged = viewModel::onDailyReminderNotificationActivatedChanged, + onMonthlyReportNotificationActivatedChanged = viewModel::onMonthlyReportNotificationActivatedChanged, + onRateAppClicked = viewModel::onRateAppClicked, + onShareAppClicked = viewModel::onShareAppClicked, + onUpdateNotificationActivatedChanged = viewModel::onUpdateNotificationActivatedChanged, + onBugReportClicked = viewModel::onBugReportClicked, + onAppClicked = viewModel::onAppClicked, + onSubscribeButtonClicked = viewModel::onSubscribeButtonClicked, + onRedeemCodeButtonClicked = viewModel::onRedeemCodeButtonClicked, + onPushPermissionResult = viewModel::onPushPermissionResult, + onAdjustLowMoneyWarningAmountChanged = viewModel::onAdjustLowMoneyWarningAmountChanged, + navigateToPremium = navigateToPremium, + onThemeSelected = viewModel::onThemeSelected, + onNotificationPermissionDeniedPromptAccepted = viewModel::onNotificationPermissionDeniedPromptAccepted, + ) +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun SettingsView( + stateFlow: StateFlow, + eventFlow: Flow, + navigateUp: () -> Unit, + navigateToBackupSettings: () -> Unit, + onRetryButtonClicked: () -> Unit, + onCurrencyChangeClicked: () -> Unit, + onAdjustLowMoneyWarningAmountClicked: () -> Unit, + onFirstDayOfWeekChanged: (DayOfWeek) -> Unit, + onPremiumButtonClicked: () -> Unit, + onProButtonClicked: () -> Unit, + onThemeClicked: () -> Unit, + onShowCheckedBalanceChanged: (Boolean) -> Unit, + onCloudBackupClicked: () -> Unit, + onDailyReminderNotificationActivatedChanged: (Boolean) -> Unit, + onMonthlyReportNotificationActivatedChanged: (Boolean) -> Unit, + onRateAppClicked: () -> Unit, + onShareAppClicked: () -> Unit, + onUpdateNotificationActivatedChanged: (Boolean) -> Unit, + onBugReportClicked: () -> Unit, + onAppClicked: () -> Unit, + onSubscribeButtonClicked: () -> Unit, + onRedeemCodeButtonClicked: () -> Unit, + onPushPermissionResult: () -> Unit, + onAdjustLowMoneyWarningAmountChanged: (Int) -> Unit, + navigateToPremium: () -> Unit, + onThemeSelected: (AppTheme) -> Unit, + onNotificationPermissionDeniedPromptAccepted: () -> Unit, +) { + val context = LocalContext.current + val pushPermissionState = rememberPermissionStateCompat { + onPushPermissionResult() + } + + var showCurrencyPickerDialog by remember { mutableStateOf(false) } + var showThemePickerDialogWithTheme by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = "eventsListener") { + launchCollect(eventFlow) { event -> + when(event) { + SettingsViewModel.Event.OpenBackupSettings -> navigateToBackupSettings() + SettingsViewModel.Event.ShowCurrencyPicker -> showCurrencyPickerDialog = true + is SettingsViewModel.Event.ShowLowMoneyWarningAmountPicker -> { + context.showLowMoneyWarningAmountPickerDialog( + lowMoneyWarningAmount = event.currentLowMoneyWarningAmount, + onLowMoneyWarningAmountChanged = onAdjustLowMoneyWarningAmountChanged, + ) + } + SettingsViewModel.Event.AskForNotificationPermission -> { + if (pushPermissionState.status.isGranted) { + onPushPermissionResult() + } else { + pushPermissionState.launchPermissionRequest() + } + } + is SettingsViewModel.Event.OpenBugReport -> { + val sendIntent = Intent() + sendIntent.action = Intent.ACTION_SENDTO + sendIntent.data = Uri.parse("mailto:") // only email apps should handle this + sendIntent.putExtra(Intent.EXTRA_EMAIL, arrayOf(context.getString(R.string.bug_report_email))) + sendIntent.putExtra(Intent.EXTRA_TEXT, context.getString(R.string.setting_category_bug_report_send_text, event.localId)) + sendIntent.putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.setting_category_bug_report_send_subject)) + + if (context.packageManager != null && sendIntent.resolveActivity(context.packageManager) != null) { + context.startActivity(sendIntent) + } else { + Toast.makeText(context, context.getString(R.string.setting_category_bug_report_send_error), Toast.LENGTH_SHORT).show() + } + } + SettingsViewModel.Event.OpenRedeemCode -> context.openRedeemCodeDialog() + SettingsViewModel.Event.OpenSubscribeScreen -> navigateToPremium() + SettingsViewModel.Event.RedirectToTwitter -> { + val i = Intent(Intent.ACTION_VIEW) + i.data = Uri.parse("https://x.com/BenoitLetondor") + context.startActivity(i) + } + is SettingsViewModel.Event.ShowAppRating -> RatingPopup(context as Activity, event.parameters).show(true) + SettingsViewModel.Event.ShowAppSharing -> { + try { + val sendIntent = Intent() + sendIntent.action = Intent.ACTION_SEND + sendIntent.putExtra(Intent.EXTRA_TEXT, context.getString(R.string.app_invite_message) + "\n" + "https://play.google.com/store/apps/details?id=com.benoitletondor.easybudgetapp") + sendIntent.type = "text/plain" + context.startActivity(sendIntent) + } catch (e: Exception) { + Logger.error("An error occurred during sharing app activity start", e) + } + } + is SettingsViewModel.Event.ShowThemePicker -> showThemePickerDialogWithTheme = event.currentTheme + SettingsViewModel.Event.ShowNotificationRejectedPrompt -> MaterialAlertDialogBuilder(context) + .setTitle(R.string.setting_notification_permission_rejected_dialog_title) + .setMessage(R.string.setting_notification_permission_rejected_dialog_description) + .setPositiveButton(R.string.setting_notification_permission_rejected_dialog_accept_cta) { dialog, _ -> + dialog.dismiss() + onNotificationPermissionDeniedPromptAccepted() + } + .setNegativeButton(R.string.setting_notification_permission_rejected_dialog_not_now_cta) { dialog, _ -> + dialog.dismiss() + } + .show() + } + } + } + + AppWithTopAppBarScaffold( + title = stringResource(R.string.title_activity_settings), + backButtonBehavior = BackButtonBehavior.NavigateBack( + onBackButtonPressed = navigateUp, + ), + content = { contentPadding -> + Box { + val state by stateFlow.collectAsState() + + when(val currentState = state) { + is SettingsViewModel.State.Error -> ErrorView( + contentPadding = contentPadding, + error = currentState.error, + onRetryButtonClicked = onRetryButtonClicked, + ) + is SettingsViewModel.State.Loaded -> Settings( + contentPadding = contentPadding, + state = currentState, + onCurrencyChangeClicked = onCurrencyChangeClicked, + onAdjustLowMoneyWarningAmountClicked = onAdjustLowMoneyWarningAmountClicked, + onFirstDayOfWeekChanged = onFirstDayOfWeekChanged, + onPremiumButtonClicked = onPremiumButtonClicked, + onProButtonClicked = onProButtonClicked, + onThemeClicked = onThemeClicked, + onShowCheckedBalanceChanged = onShowCheckedBalanceChanged, + onCloudBackupClicked = onCloudBackupClicked, + onDailyReminderNotificationActivatedChanged = onDailyReminderNotificationActivatedChanged, + onMonthlyReportNotificationActivatedChanged = onMonthlyReportNotificationActivatedChanged, + onRateAppClicked = onRateAppClicked, + onShareAppClicked = onShareAppClicked, + onUpdateNotificationActivatedChanged= onUpdateNotificationActivatedChanged, + onBugReportClicked = onBugReportClicked, + onAppClicked = onAppClicked, + onSubscribeButtonClicked = onSubscribeButtonClicked, + onRedeemCodeButtonClicked = onRedeemCodeButtonClicked, + ) + SettingsViewModel.State.Loading -> LoadingView( + modifier = Modifier.padding(contentPadding), + ) + } + + if (showCurrencyPickerDialog) { + SelectCurrencyDialog( + contentPadding = contentPadding, + onDismissRequest = { showCurrencyPickerDialog = false }, + ) + } + + val currentThemeForThemePicker = showThemePickerDialogWithTheme + if (currentThemeForThemePicker != null) { + ThemePickerDialog( + contentPadding = contentPadding, + currentTheme = currentThemeForThemePicker, + onThemeSelected = { + showThemePickerDialogWithTheme = null + onThemeSelected(it) + }, + onDismissRequest = { + showThemePickerDialogWithTheme = null + }, + ) + } + } + } + ) +} + diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/SettingsViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/SettingsViewModel.kt new file mode 100644 index 00000000..7cc7793e --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/SettingsViewModel.kt @@ -0,0 +1,360 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.settings + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.benoitletondor.easybudgetapp.helper.AppTheme +import com.benoitletondor.easybudgetapp.helper.Logger +import com.benoitletondor.easybudgetapp.helper.MutableLiveFlow +import com.benoitletondor.easybudgetapp.helper.combine +import com.benoitletondor.easybudgetapp.helper.watchUserCurrency +import com.benoitletondor.easybudgetapp.iab.Iab +import com.benoitletondor.easybudgetapp.iab.PremiumCheckStatus +import com.benoitletondor.easybudgetapp.parameters.Parameters +import com.benoitletondor.easybudgetapp.parameters.setFirstDayOfWeek +import com.benoitletondor.easybudgetapp.parameters.watchFirstDayOfWeek +import com.benoitletondor.easybudgetapp.parameters.watchIsBackupEnabled +import com.benoitletondor.easybudgetapp.parameters.watchLowMoneyWarningAmount +import com.benoitletondor.easybudgetapp.parameters.watchShouldShowCheckedBalance +import com.benoitletondor.easybudgetapp.parameters.watchTheme +import com.benoitletondor.easybudgetapp.parameters.watchUserAllowingDailyReminderPushes +import com.benoitletondor.easybudgetapp.parameters.watchUserAllowingMonthlyReminderPushes +import com.benoitletondor.easybudgetapp.parameters.watchUserAllowingUpdatePushes +import com.benoitletondor.easybudgetapp.BuildConfig +import com.benoitletondor.easybudgetapp.parameters.getLocalId +import com.benoitletondor.easybudgetapp.parameters.setLowMoneyWarningAmount +import com.benoitletondor.easybudgetapp.parameters.setShouldShowCheckedBalance +import com.benoitletondor.easybudgetapp.parameters.setTheme +import com.benoitletondor.easybudgetapp.parameters.setUserAllowDailyReminderPushes +import com.benoitletondor.easybudgetapp.parameters.setUserAllowMonthlyReminderPushes +import com.benoitletondor.easybudgetapp.parameters.setUserAllowUpdatePushes +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.DayOfWeek +import java.util.Currency +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val parameters: Parameters, + iab: Iab, + @ApplicationContext private val context: Context, +) : ViewModel() { + private val retryLoadingMutableFlow = MutableSharedFlow() + + private val isNotificationPermissionGrantedMutableFlow = MutableStateFlow(isNotificationPermissionGranted()) + + val stateFlow: StateFlow = combine( + parameters.watchUserCurrency(), + parameters.watchLowMoneyWarningAmount(), + parameters.watchFirstDayOfWeek(), + iab.iabStatusFlow + .flatMapLatest { iabStatus -> + when(iabStatus) { + PremiumCheckStatus.INITIALIZING, + PremiumCheckStatus.CHECKING -> flowOf(SubscriptionStatus.Loading) + PremiumCheckStatus.ERROR -> flowOf(SubscriptionStatus.Error) + PremiumCheckStatus.NOT_PREMIUM -> flowOf(SubscriptionStatus.NotSubscribed) + PremiumCheckStatus.LEGACY_PREMIUM, + PremiumCheckStatus.PREMIUM_SUBSCRIBED, + PremiumCheckStatus.PRO_SUBSCRIBED -> { + combine( + parameters.watchIsBackupEnabled(), + parameters.watchTheme(), + parameters.watchShouldShowCheckedBalance(), + isNotificationPermissionGrantedMutableFlow, + parameters.watchUserAllowingDailyReminderPushes(), + parameters.watchUserAllowingMonthlyReminderPushes(), + ) { isBackupEnabled, theme, showCheckedBalance, isNotificationPermissionGranted, dailyReminderActivated, monthlyReportNotificationActivated -> + when(iabStatus) { + PremiumCheckStatus.LEGACY_PREMIUM, + PremiumCheckStatus.PREMIUM_SUBSCRIBED -> SubscriptionStatus.PremiumSubscribed( + isBackupEnabled, + theme, + showCheckedBalance, + dailyReminderActivated = isNotificationPermissionGranted && dailyReminderActivated, + monthlyReportNotificationActivated = isNotificationPermissionGranted && monthlyReportNotificationActivated, + ) + PremiumCheckStatus.PRO_SUBSCRIBED -> SubscriptionStatus.ProSubscribed( + isBackupEnabled, + theme, + showCheckedBalance, + dailyReminderActivated = isNotificationPermissionGranted && dailyReminderActivated, + monthlyReportNotificationActivated = isNotificationPermissionGranted && monthlyReportNotificationActivated, + ) + else -> throw IllegalStateException("Unable to handle status $iabStatus") + } + + } + } + } + }, + isNotificationPermissionGrantedMutableFlow, + parameters.watchUserAllowingUpdatePushes(), + ) { userCurrency, lowMoneyWarningAmount, firstDayOfWeek, subscriptionStatus, isNotificationPermissionGranted, userAllowingUpdatePushes -> + return@combine State.Loaded( + userCurrency, + lowMoneyWarningAmount, + firstDayOfWeek, + subscriptionStatus, + userAllowingUpdatePushes = isNotificationPermissionGranted && userAllowingUpdatePushes, + appVersion = BuildConfig.VERSION_NAME, + ) as State + } + .retryWhen { cause, _ -> + Logger.error("Error loading settings", cause) + emit(State.Error(cause)) + + retryLoadingMutableFlow.first() + emit(State.Loading) + + true + } + .stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) + + private val eventMutableFlow = MutableLiveFlow() + val eventFlow: Flow = eventMutableFlow + + fun onRetryButtonPressed() { + viewModelScope.launch { + retryLoadingMutableFlow.emit(Unit) + } + } + + fun onCurrencyChangeClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowCurrencyPicker) + } + } + + fun onAdjustLowMoneyWarningAmountClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowLowMoneyWarningAmountPicker((stateFlow.value as State.Loaded).lowMoneyWarningAmount)) + } + } + + fun onFirstDayOfWeekChanged(dayOfWeek: DayOfWeek) { + viewModelScope.launch { + parameters.setFirstDayOfWeek(dayOfWeek) + } + } + + fun onPremiumButtonClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenSubscribeScreen) + } + } + + fun onProButtonClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenSubscribeScreen) + } + } + + fun onThemeClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowThemePicker( + currentTheme = ((stateFlow.value as State.Loaded).subscriptionStatus as SubscriptionStatus.Subscribed).theme), + ) + } + } + + fun onShowCheckedBalanceChanged(showCheckedBalance: Boolean) { + viewModelScope.launch { + parameters.setShouldShowCheckedBalance(showCheckedBalance) + } + } + + fun onCloudBackupClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenBackupSettings) + } + } + + fun onDailyReminderNotificationActivatedChanged(activated: Boolean) { + viewModelScope.launch { + parameters.setUserAllowDailyReminderPushes(activated) + if (activated) { + eventMutableFlow.emit(Event.AskForNotificationPermission) + } + } + } + + fun onMonthlyReportNotificationActivatedChanged(activated: Boolean) { + viewModelScope.launch { + parameters.setUserAllowMonthlyReminderPushes(activated) + if (activated) { + eventMutableFlow.emit(Event.AskForNotificationPermission) + } + } + } + + fun onPushPermissionResult() { + val granted = isNotificationPermissionGranted() + + isNotificationPermissionGrantedMutableFlow.value = granted + + if (!granted) { + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowNotificationRejectedPrompt) + } + } + } + + fun onRateAppClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowAppRating(parameters = parameters)) + } + } + + fun onShareAppClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.ShowAppSharing) + } + } + + fun onUpdateNotificationActivatedChanged(activated: Boolean) { + viewModelScope.launch { + parameters.setUserAllowUpdatePushes(activated) + if (activated) { + eventMutableFlow.emit(Event.AskForNotificationPermission) + } + } + } + + fun onBugReportClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenBugReport( + localId = parameters.getLocalId() ?: "UNKNOWN_LOCAL_ID" + )) + } + } + + fun onAppClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.RedirectToTwitter) + } + } + + fun onSubscribeButtonClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenSubscribeScreen) + } + } + + fun onRedeemCodeButtonClicked() { + viewModelScope.launch { + eventMutableFlow.emit(Event.OpenRedeemCode) + } + } + + fun onAdjustLowMoneyWarningAmountChanged(newLowMoneyWarningAmount: Int) { + viewModelScope.launch { + parameters.setLowMoneyWarningAmount(newLowMoneyWarningAmount) + } + } + + fun onThemeSelected(theme: AppTheme) { + viewModelScope.launch { + parameters.setTheme(theme) + AppCompatDelegate.setDefaultNightMode(theme.toPlatformValue()) + } + } + + fun onNotificationPermissionDeniedPromptAccepted() { + viewModelScope.launch { + eventMutableFlow.emit(Event.AskForNotificationPermission) + } + } + + private fun isNotificationPermissionGranted(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } else { + true + } + + sealed class State { + data object Loading : State() + data class Error(val error: Throwable) : State() + data class Loaded( + val userCurrency: Currency, + val lowMoneyWarningAmount: Int, + val firstDayOfWeek: DayOfWeek, + val subscriptionStatus: SubscriptionStatus, + val userAllowingUpdatePushes: Boolean, + val appVersion: String, + ) : State() + } + + sealed class SubscriptionStatus { + sealed interface Subscribed { + val cloudBackupEnabled: Boolean + val theme: AppTheme + val showCheckedBalance: Boolean + val dailyReminderActivated: Boolean + val monthlyReportNotificationActivated: Boolean + } + + data object Loading : SubscriptionStatus() + data object Error : SubscriptionStatus() + data object NotSubscribed : SubscriptionStatus() + data class ProSubscribed( + override val cloudBackupEnabled: Boolean, + override val theme: AppTheme, + override val showCheckedBalance: Boolean, + override val dailyReminderActivated: Boolean, + override val monthlyReportNotificationActivated: Boolean + ) : SubscriptionStatus(), Subscribed + data class PremiumSubscribed( + override val cloudBackupEnabled: Boolean, + override val theme: AppTheme, + override val showCheckedBalance: Boolean, + override val dailyReminderActivated: Boolean, + override val monthlyReportNotificationActivated: Boolean + ) : SubscriptionStatus(), Subscribed + } + + sealed class Event { + data object OpenBackupSettings : Event() + data object ShowCurrencyPicker : Event() + data class ShowLowMoneyWarningAmountPicker(val currentLowMoneyWarningAmount: Int) : Event() + data object OpenSubscribeScreen : Event() + data class ShowThemePicker(val currentTheme: AppTheme) : Event() + data object AskForNotificationPermission : Event() + data class ShowAppRating(val parameters: Parameters) : Event() + data object ShowAppSharing : Event() + data class OpenBugReport(val localId: String) : Event() + data object RedirectToTwitter : Event() + data object OpenRedeemCode : Event() + data object ShowNotificationRejectedPrompt : Event() + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/BackupSettingsActivity.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/BackupSettingsActivity.kt deleted file mode 100644 index d8f19bac..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/BackupSettingsActivity.kt +++ /dev/null @@ -1,352 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.settings.backup - -import android.content.Intent -import android.os.Bundle -import android.text.format.DateUtils -import android.view.MenuItem -import android.view.View -import androidx.activity.viewModels -import androidx.lifecycle.lifecycleScope -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.databinding.ActivityBackupSettingsBinding -import com.benoitletondor.easybudgetapp.helper.BaseActivity -import com.benoitletondor.easybudgetapp.helper.launchCollect -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import java.util.* -import kotlin.system.exitProcess - -@AndroidEntryPoint -class BackupSettingsActivity : BaseActivity() { - private val viewModel: BackupSettingsViewModel by viewModels() - - override fun createBinding(): ActivityBackupSettingsBinding = ActivityBackupSettingsBinding.inflate(layoutInflater) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - lifecycleScope.launchCollect(viewModel.cloudBackupStateFlow) { cloudBackupState -> - when (cloudBackupState) { - BackupCloudStorageState.NotAuthenticated -> { - binding.backupSettingsCloudStorageNotAuthenticatedState.visibility = View.VISIBLE - binding.backupSettingsCloudStorageAuthenticatingState.visibility = View.GONE - binding.backupSettingsCloudStorageNotActivatedState.visibility = View.GONE - } - BackupCloudStorageState.Authenticating -> { - binding.backupSettingsCloudStorageNotAuthenticatedState.visibility = View.GONE - binding.backupSettingsCloudStorageAuthenticatingState.visibility = View.VISIBLE - binding.backupSettingsCloudStorageNotActivatedState.visibility = View.GONE - } - is BackupCloudStorageState.NotActivated -> { - binding.backupSettingsCloudStorageNotAuthenticatedState.visibility = View.GONE - binding.backupSettingsCloudStorageAuthenticatingState.visibility = View.GONE - binding.backupSettingsCloudStorageNotActivatedState.visibility = View.VISIBLE - - binding.backupSettingsCloudStorageEmail.text = cloudBackupState.currentUser.email - binding.backupSettingsCloudStorageLogoutButton.visibility = View.VISIBLE - binding.backupSettingsCloudStorageBackupSwitch.visibility = View.VISIBLE - binding.backupSettingsCloudStorageBackupSwitchDescription.visibility = View.VISIBLE - binding.backupSettingsCloudStorageBackupSwitchDescription.text = getString( - R.string.backup_settings_cloud_backup_status, - getString(R.string.backup_settings_cloud_backup_status_disabled), - ) - binding.backupSettingsCloudStorageBackupSwitch.isChecked = false - binding.backupSettingsCloudStorageActivatedDescription.visibility = View.GONE - binding.backupSettingsCloudLastUpdate.visibility = View.GONE - binding.backupSettingsCloudBackupCta.visibility = View.GONE - binding.backupSettingsCloudRestoreCta.visibility = View.GONE - binding.backupSettingsCloudStorageRestoreDescription.visibility = View.GONE - binding.backupSettingsCloudStorageRestoreExplanation.visibility = View.GONE - binding.backupSettingsCloudStorageDeleteTitle.visibility = View.GONE - binding.backupSettingsCloudStorageDeleteExplanation.visibility = View.GONE - binding.backupSettingsCloudDeleteCta.visibility = View.GONE - binding.backupSettingsCloudBackupLoadingProgress.visibility = View.GONE - } - is BackupCloudStorageState.Activated -> { - binding.backupSettingsCloudStorageNotAuthenticatedState.visibility = View.GONE - binding.backupSettingsCloudStorageAuthenticatingState.visibility = View.GONE - binding.backupSettingsCloudStorageNotActivatedState.visibility = View.VISIBLE - - binding.backupSettingsCloudStorageEmail.text = cloudBackupState.currentUser.email - binding.backupSettingsCloudStorageLogoutButton.visibility = View.VISIBLE - binding.backupSettingsCloudStorageBackupSwitchDescription.visibility = View.VISIBLE - binding.backupSettingsCloudStorageBackupSwitchDescription.text = getString( - R.string.backup_settings_cloud_backup_status, - getString(R.string.backup_settings_cloud_backup_status_activated), - ) - binding.backupSettingsCloudStorageBackupSwitch.visibility = View.VISIBLE - binding.backupSettingsCloudStorageBackupSwitch.isChecked = true - binding.backupSettingsCloudStorageActivatedDescription.visibility = View.VISIBLE - showLastUpdateDate(cloudBackupState.lastBackupDate) - binding.backupSettingsCloudLastUpdate.visibility = View.VISIBLE - - if (cloudBackupState.backupNowAvailable) { - binding.backupSettingsCloudBackupCta.visibility = View.VISIBLE - } else { - binding.backupSettingsCloudBackupCta.visibility = View.GONE - } - - if (cloudBackupState.restoreAvailable) { - binding.backupSettingsCloudStorageRestoreDescription.visibility = View.VISIBLE - binding.backupSettingsCloudStorageRestoreExplanation.visibility = View.VISIBLE - binding.backupSettingsCloudRestoreCta.visibility = View.VISIBLE - - binding.backupSettingsCloudStorageDeleteTitle.visibility = View.VISIBLE - binding.backupSettingsCloudStorageDeleteExplanation.visibility = View.VISIBLE - binding.backupSettingsCloudDeleteCta.visibility = View.VISIBLE - } else { - binding.backupSettingsCloudRestoreCta.visibility = View.GONE - binding.backupSettingsCloudStorageRestoreDescription.visibility = View.GONE - binding.backupSettingsCloudStorageRestoreExplanation.visibility = View.GONE - - binding.backupSettingsCloudStorageDeleteTitle.visibility = View.GONE - binding.backupSettingsCloudStorageDeleteExplanation.visibility = View.GONE - binding.backupSettingsCloudDeleteCta.visibility = View.GONE - } - - binding.backupSettingsCloudBackupLoadingProgress.visibility = View.GONE - } - is BackupCloudStorageState.BackupInProgress -> { - binding.backupSettingsCloudStorageNotAuthenticatedState.visibility = View.GONE - binding.backupSettingsCloudStorageAuthenticatingState.visibility = View.GONE - binding.backupSettingsCloudStorageNotActivatedState.visibility = View.VISIBLE - - binding.backupSettingsCloudStorageEmail.text = cloudBackupState.currentUser.email - binding.backupSettingsCloudStorageLogoutButton.visibility = View.GONE - binding.backupSettingsCloudStorageBackupSwitch.visibility = View.GONE - binding.backupSettingsCloudStorageBackupSwitchDescription.visibility = View.GONE - binding.backupSettingsCloudStorageActivatedDescription.visibility = View.GONE - binding.backupSettingsCloudLastUpdate.visibility = View.GONE - binding.backupSettingsCloudBackupCta.visibility = View.GONE - binding.backupSettingsCloudRestoreCta.visibility = View.GONE - binding.backupSettingsCloudStorageRestoreDescription.visibility = View.GONE - binding.backupSettingsCloudStorageRestoreExplanation.visibility = View.GONE - binding.backupSettingsCloudStorageDeleteTitle.visibility = View.GONE - binding.backupSettingsCloudStorageDeleteExplanation.visibility = View.GONE - binding.backupSettingsCloudDeleteCta.visibility = View.GONE - binding.backupSettingsCloudBackupLoadingProgress.visibility = View.VISIBLE - } - is BackupCloudStorageState.RestorationInProgress -> { - binding.backupSettingsCloudStorageNotAuthenticatedState.visibility = View.GONE - binding.backupSettingsCloudStorageAuthenticatingState.visibility = View.GONE - binding.backupSettingsCloudStorageNotActivatedState.visibility = View.VISIBLE - - binding.backupSettingsCloudStorageEmail.text = cloudBackupState.currentUser.email - binding.backupSettingsCloudStorageLogoutButton.visibility = View.GONE - binding.backupSettingsCloudStorageBackupSwitch.visibility = View.GONE - binding.backupSettingsCloudStorageBackupSwitchDescription.visibility = View.GONE - binding.backupSettingsCloudStorageActivatedDescription.visibility = View.GONE - binding.backupSettingsCloudLastUpdate.visibility = View.GONE - binding.backupSettingsCloudBackupCta.visibility = View.GONE - binding.backupSettingsCloudRestoreCta.visibility = View.GONE - binding.backupSettingsCloudStorageRestoreDescription.visibility = View.GONE - binding.backupSettingsCloudStorageRestoreExplanation.visibility = View.GONE - binding.backupSettingsCloudStorageDeleteTitle.visibility = View.GONE - binding.backupSettingsCloudStorageDeleteExplanation.visibility = View.GONE - binding.backupSettingsCloudDeleteCta.visibility = View.GONE - binding.backupSettingsCloudBackupLoadingProgress.visibility = View.VISIBLE - } - is BackupCloudStorageState.DeletionInProgress -> { - binding.backupSettingsCloudStorageNotAuthenticatedState.visibility = View.GONE - binding.backupSettingsCloudStorageAuthenticatingState.visibility = View.GONE - binding.backupSettingsCloudStorageNotActivatedState.visibility = View.VISIBLE - - binding.backupSettingsCloudStorageEmail.text = cloudBackupState.currentUser.email - binding.backupSettingsCloudStorageLogoutButton.visibility = View.GONE - binding.backupSettingsCloudStorageBackupSwitch.visibility = View.GONE - binding.backupSettingsCloudStorageBackupSwitchDescription.visibility = View.GONE - binding.backupSettingsCloudStorageActivatedDescription.visibility = View.GONE - binding.backupSettingsCloudLastUpdate.visibility = View.GONE - binding.backupSettingsCloudBackupCta.visibility = View.GONE - binding.backupSettingsCloudRestoreCta.visibility = View.GONE - binding.backupSettingsCloudStorageRestoreDescription.visibility = View.GONE - binding.backupSettingsCloudStorageRestoreExplanation.visibility = View.GONE - binding.backupSettingsCloudStorageDeleteTitle.visibility = View.GONE - binding.backupSettingsCloudStorageDeleteExplanation.visibility = View.GONE - binding.backupSettingsCloudDeleteCta.visibility = View.GONE - binding.backupSettingsCloudBackupLoadingProgress.visibility = View.VISIBLE - } - } - } - - lifecycleScope.launchCollect(viewModel.backupNowErrorEventFlow) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.backup_now_error_title) - .setMessage(R.string.backup_now_error_message) - .setPositiveButton(android.R.string.ok, null) - } - - lifecycleScope.launchCollect(viewModel.previousBackupAvailableEventFlow) { lastBackupDate -> - MaterialAlertDialogBuilder(this) - .setTitle(R.string.backup_already_exist_title) - .setMessage( - getString( - R.string.backup_already_exist_message, - lastBackupDate.formatLastBackupDate() - ) - ) - .setPositiveButton(R.string.backup_already_exist_positive_cta) { _, _ -> - viewModel.onRestorePreviousBackupButtonPressed() - } - .setNegativeButton(R.string.backup_already_exist_negative_cta) { _, _ -> - viewModel.onIgnorePreviousBackupButtonPressed() - } - .show() - } - - lifecycleScope.launchCollect(viewModel.restorationErrorEventFlow) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.backup_restore_error_title) - .setMessage(R.string.backup_restore_error_message) - .setPositiveButton(android.R.string.ok, null) - } - - lifecycleScope.launchCollect(viewModel.appRestartEventFlow) { - val intent = packageManager.getLaunchIntentForPackage(packageName) - finishAffinity() - startActivity(intent) - exitProcess(0) - } - - lifecycleScope.launchCollect(viewModel.restoreConfirmationDisplayEventFlow) { lastBackupDate -> - MaterialAlertDialogBuilder(this) - .setTitle(R.string.backup_restore_confirmation_title) - .setMessage( - getString( - R.string.backup_restore_confirmation_message, - lastBackupDate.formatLastBackupDate() - ) - ) - .setPositiveButton(R.string.backup_restore_confirmation_positive_cta) { _, _ -> - viewModel.onRestoreBackupConfirmationConfirmed() - } - .setNegativeButton(R.string.backup_restore_confirmation_negative_cta) { _, _ -> - viewModel.onRestoreBackupConfirmationCancelled() - } - .show() - } - - lifecycleScope.launchCollect(viewModel.authenticationConfirmationDisplayEventFlow) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.backup_settings_not_authenticated_privacy_title) - .setMessage(R.string.backup_settings_not_authenticated_privacy_message) - .setPositiveButton(R.string.backup_settings_not_authenticated_privacy_positive_cta) { _, _ -> - viewModel.onAuthenticationConfirmationConfirmed(this) - } - .setNegativeButton(R.string.backup_settings_not_authenticated_privacy_negative_cta) { _, _ -> - viewModel.onAuthenticationConfirmationCancelled() - } - .show() - } - - lifecycleScope.launchCollect(viewModel.deleteConfirmationDisplayEventFlow) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.backup_wipe_data_confirmation_title) - .setMessage(R.string.backup_wipe_data_confirmation_message) - .setPositiveButton(R.string.backup_wipe_data_confirmation_positive_cta) { _, _ -> - viewModel.onDeleteBackupConfirmationConfirmed() - } - .setNegativeButton(R.string.backup_wipe_data_confirmation_negative_cta) { _, _ -> - viewModel.onDeleteBackupConfirmationCancelled() - } - .show() - } - - lifecycleScope.launchCollect(viewModel.backupDeletionErrorEventFlow) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.backup_wipe_data_error_title) - .setMessage(R.string.backup_wipe_data_error_message) - .setPositiveButton(android.R.string.ok, null) - .show() - } - - binding.backupSettingsCloudStorageAuthenticateButton.setOnClickListener { - viewModel.onAuthenticateButtonPressed() - } - - binding.backupSettingsCloudStorageLogoutButton.setOnClickListener { - viewModel.onLogoutButtonPressed() - } - - binding.backupSettingsCloudStorageBackupSwitch.setOnCheckedChangeListener { _, checked -> - if( checked ) { - viewModel.onBackupActivated() - } else { - viewModel.onBackupDeactivated() - } - } - - binding.backupSettingsCloudBackupCta.setOnClickListener { - viewModel.onBackupNowButtonPressed() - } - - binding.backupSettingsCloudRestoreCta.setOnClickListener { - viewModel.onRestoreButtonPressed() - } - - binding.backupSettingsCloudDeleteCta.setOnClickListener { - viewModel.onDeleteBackupButtonPressed() - } - } - - private fun showLastUpdateDate(lastBackupDate: Date?) { - binding.backupSettingsCloudLastUpdate.text = if( lastBackupDate != null ) { - val timeFormatted = DateUtils.getRelativeDateTimeString( - this, - lastBackupDate.time, - DateUtils.MINUTE_IN_MILLIS, - DateUtils.WEEK_IN_MILLIS, - DateUtils.FORMAT_SHOW_TIME - ) - - getString(R.string.backup_last_update_date, timeFormatted) - } else { - getString(R.string.backup_last_update_date, getString(R.string.backup_last_update_date_never)) - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - finish() - return true - } - } - - return super.onOptionsItemSelected(item) - } - - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - - viewModel.handleActivityResult(requestCode, resultCode, data) - } - - private fun Date.formatLastBackupDate(): String { - return DateUtils.formatDateTime( - this@BackupSettingsActivity, - this.time, - DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR - ) - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/BackupSettingsView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/BackupSettingsView.kt new file mode 100644 index 00000000..bf902d03 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/BackupSettingsView.kt @@ -0,0 +1,266 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.settings.backup + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.text.format.DateUtils +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.compose.AppWithTopAppBarScaffold +import com.benoitletondor.easybudgetapp.compose.BackButtonBehavior +import com.benoitletondor.easybudgetapp.compose.components.LoadingView +import com.benoitletondor.easybudgetapp.helper.launchCollect +import com.benoitletondor.easybudgetapp.view.settings.backup.subviews.AuthenticatedView +import com.benoitletondor.easybudgetapp.view.settings.backup.subviews.NotAuthenticatedView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.Serializable +import java.util.Date +import kotlin.system.exitProcess + +@Serializable +object BackupSettingsDestination + +@Composable +fun BackupSettingsView( + viewModel: BackupSettingsViewModel = hiltViewModel(), + navigateUp: () -> Unit, +) { + BackupSettingsView( + stateFlow = viewModel.stateFlow, + eventFlow = viewModel.eventFlow, + navigateUp = navigateUp, + onLoginButtonClicked = viewModel::onAuthenticateButtonPressed, + onAuthenticationConfirmationConfirmed = viewModel::onAuthenticationConfirmationConfirmed, + onAuthenticationConfirmationCancelled = viewModel::onAuthenticationConfirmationCancelled, + onRestoreBackupConfirmationConfirmed = viewModel::onRestoreBackupConfirmationConfirmed, + onRestoreBackupConfirmationCancelled = viewModel::onRestoreBackupConfirmationCancelled, + onDeleteBackupConfirmationConfirmed = viewModel::onDeleteBackupConfirmationConfirmed, + onDeleteBackupConfirmationCancelled = viewModel::onDeleteBackupConfirmationCancelled, + onAuthActivityResult = viewModel::handleAuthActivityResult, + onLogoutButtonClicked = viewModel::onLogoutButtonPressed, + onBackupActivationChange = { backupActivated -> + if (backupActivated) { + viewModel.onBackupActivated() + } else { + viewModel.onBackupDeactivated() + } + }, + onBackupNowClicked = viewModel::onBackupNowButtonPressed, + onRestoreNowClicked = viewModel::onRestoreButtonPressed, + onDeleteBackupClicked = viewModel::onDeleteBackupButtonPressed, + onRestorePreviousBackupButtonPressed = viewModel::onRestorePreviousBackupButtonPressed, + onIgnorePreviousBackupButtonPressed = viewModel::onIgnorePreviousBackupButtonPressed, + ) +} + +@Composable +private fun BackupSettingsView( + stateFlow: StateFlow, + eventFlow: Flow, + navigateUp: () -> Unit, + onLoginButtonClicked: () -> Unit, + onAuthenticationConfirmationConfirmed: (ManagedActivityResultLauncher) -> Unit, + onAuthenticationConfirmationCancelled: () -> Unit, + onRestoreBackupConfirmationConfirmed: () -> Unit, + onRestoreBackupConfirmationCancelled: () -> Unit, + onDeleteBackupConfirmationConfirmed: () -> Unit, + onDeleteBackupConfirmationCancelled: () -> Unit, + onAuthActivityResult: (ActivityResult) -> Unit, + onLogoutButtonClicked: () -> Unit, + onBackupActivationChange: (Boolean) -> Unit, + onBackupNowClicked: () -> Unit, + onRestoreNowClicked: () -> Unit, + onDeleteBackupClicked: () -> Unit, + onRestorePreviousBackupButtonPressed: () -> Unit, + onIgnorePreviousBackupButtonPressed: () -> Unit, +) { + val context = LocalContext.current + + val authActivityLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + onAuthActivityResult(result) + } + + LaunchedEffect(key1 = "eventsListener") { + launchCollect(eventFlow) { event -> + when (event) { + is BackupSettingsViewModel.Event.PromptUserToRestorePreviousBackup -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.backup_already_exist_title) + .setMessage( + context.getString( + R.string.backup_already_exist_message, + event.lastBackupDate.formatLastBackupDate(context), + ) + ) + .setPositiveButton(R.string.backup_restore_confirmation_positive_cta) { _, _ -> + onRestorePreviousBackupButtonPressed() + } + .setNegativeButton(R.string.backup_restore_confirmation_negative_cta) { _, _ -> + onIgnorePreviousBackupButtonPressed() + } + .show() + } + BackupSettingsViewModel.Event.RestartApp -> { + val activity = context as Activity + val intent = activity.packageManager.getLaunchIntentForPackage(activity.packageName) + activity.finishAffinity() + activity.startActivity(intent) + exitProcess(0) + } + BackupSettingsViewModel.Event.ShowAuthenticationConfirmation -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.backup_settings_not_authenticated_privacy_title) + .setMessage(R.string.backup_settings_not_authenticated_privacy_message) + .setPositiveButton(R.string.backup_settings_not_authenticated_privacy_positive_cta) { _, _ -> + onAuthenticationConfirmationConfirmed(authActivityLauncher) + } + .setNegativeButton(R.string.backup_settings_not_authenticated_privacy_negative_cta) { _, _ -> + onAuthenticationConfirmationCancelled() + } + .show() + } + is BackupSettingsViewModel.Event.ShowBackupDeletionError -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.backup_wipe_data_error_title) + .setMessage(R.string.backup_wipe_data_error_message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + is BackupSettingsViewModel.Event.ShowBackupNowError -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.backup_now_error_title) + .setMessage(R.string.backup_now_error_message) + .setPositiveButton(android.R.string.ok, null) + } + BackupSettingsViewModel.Event.ShowDeleteConfirmation -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.backup_wipe_data_confirmation_title) + .setMessage(R.string.backup_wipe_data_confirmation_message) + .setPositiveButton(R.string.backup_wipe_data_confirmation_positive_cta) { _, _ -> + onDeleteBackupConfirmationConfirmed() + } + .setNegativeButton(R.string.backup_wipe_data_confirmation_negative_cta) { _, _ -> + onDeleteBackupConfirmationCancelled() + } + .show() + } + is BackupSettingsViewModel.Event.ShowRestoreConfirmation -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.backup_restore_confirmation_title) + .setMessage( + context.getString( + R.string.backup_restore_confirmation_message, + event.lastBackupDate.formatLastBackupDate(context), + ) + ) + .setPositiveButton(R.string.backup_restore_confirmation_positive_cta) { _, _ -> + onRestoreBackupConfirmationConfirmed() + } + .setNegativeButton(R.string.backup_restore_confirmation_negative_cta) { _, _ -> + onRestoreBackupConfirmationCancelled() + } + .show() + } + is BackupSettingsViewModel.Event.ShowRestoreError -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.backup_restore_error_title) + .setMessage(R.string.backup_restore_error_message) + .setPositiveButton(android.R.string.ok, null) + } + } + } + } + + AppWithTopAppBarScaffold( + title = stringResource(R.string.backup_settings_activity_title), + backButtonBehavior = BackButtonBehavior.NavigateBack( + onBackButtonPressed = navigateUp, + ), + content = { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.backup_settings_cloud_backup), + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + val state by stateFlow.collectAsState() + when(val currentState = state) { + BackupSettingsViewModel.State.NotAuthenticated -> NotAuthenticatedView( + onLoginButtonClicked = onLoginButtonClicked, + ) + BackupSettingsViewModel.State.Authenticating -> LoadingView() + is BackupSettingsViewModel.State.Authenticated -> { + AuthenticatedView( + state = currentState, + onLogoutButtonClicked = onLogoutButtonClicked, + onBackupActivationChange = onBackupActivationChange, + onBackupNowClicked = onBackupNowClicked, + onRestoreNowClicked = onRestoreNowClicked, + onDeleteBackupClicked = onDeleteBackupClicked, + ) + } + } + } + }, + ) +} + +private fun Date.formatLastBackupDate(context: Context): String { + return DateUtils.formatDateTime( + context, + this.time, + DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR + ) +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/BackupSettingsViewModel.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/BackupSettingsViewModel.kt index e8765eb5..36cdb284 100644 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/BackupSettingsViewModel.kt +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/BackupSettingsViewModel.kt @@ -16,9 +16,10 @@ package com.benoitletondor.easybudgetapp.view.settings.backup -import android.app.Activity import android.content.Context import android.content.Intent +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.result.ActivityResult import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.ListenableWorker @@ -35,11 +36,20 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import java.util.* import java.lang.RuntimeException import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds @HiltViewModel class BackupSettingsViewModel @Inject constructor( @@ -50,86 +60,90 @@ class BackupSettingsViewModel @Inject constructor( @ApplicationContext private val appContext: Context, ) : ViewModel() { - private val cloudBackupStateMutableFlow = MutableStateFlow(BackupCloudStorageState.NotAuthenticated) - val cloudBackupStateFlow: Flow = cloudBackupStateMutableFlow + private val eventMutableFlow = MutableLiveFlow() + val eventFlow: Flow = eventMutableFlow - private val authenticationConfirmationDisplayEventMutableFlow = MutableLiveFlow() - val authenticationConfirmationDisplayEventFlow : Flow = authenticationConfirmationDisplayEventMutableFlow + private val backupInProgressFlow = MutableStateFlow(false) + private val restorationInProgressFlow = MutableStateFlow(false) + private val deletionInProgressFlow = MutableStateFlow(false) - private val backupNowErrorEventMutableFlow = MutableLiveFlow() - val backupNowErrorEventFlow: Flow = backupNowErrorEventMutableFlow - - private val restorationErrorEventMutableFlow = MutableLiveFlow() - val restorationErrorEventFlow: Flow = restorationErrorEventMutableFlow - - private val previousBackupAvailableEventMutableFlow = MutableLiveFlow() - val previousBackupAvailableEventFlow: Flow = previousBackupAvailableEventMutableFlow - - private val appRestartEventMutableFlow = MutableLiveFlow() - val appRestartEventFlow: Flow = appRestartEventMutableFlow - - private val restoreConfirmationDisplayEventMutableFlow = MutableLiveFlow() - val restoreConfirmationDisplayEventFlow: Flow = restoreConfirmationDisplayEventMutableFlow - - private val deleteConfirmationDisplayEventMutableFlow = MutableLiveFlow() - val deleteConfirmationDisplayEventFlow: Flow = deleteConfirmationDisplayEventMutableFlow - - private val backupDeletionErrorEventMutableFlow = MutableLiveFlow() - val backupDeletionErrorEventFlow: Flow = backupDeletionErrorEventMutableFlow - - private var backupInProgress = false - private var restorationInProgress = false - private var deletionInProgress = false - - init { - viewModelScope.launchCollect(auth.state) { authState -> - if( authState is AuthState.Authenticated ) { - viewModelScope.launch { - withContext(Dispatchers.IO) { + val stateFlow: StateFlow = combine( + auth.state + .onEach { authState -> + if (authState is AuthState.Authenticated) { + viewModelScope.launch(Dispatchers.IO) { try { - if( parameters.getLastBackupDate() == null ) { + if (parameters.getLastBackupDate() == null) { getBackupDBMetaData(cloudStorage, auth)?.let { parameters.saveLastBackupDate(it.lastUpdateDate) } } } catch (e: Throwable) { - Logger.error( - "Error getting last backup date", - e - ) - } + if (e is CancellationException) throw e - null // ?! not sure why it's needed + Logger.error("Error getting last backup date", e) + } } + } + }, + parameters.watchIsBackupEnabled(), + parameters.watchLastBackupDate(), + getBackupJobInfosFlow(appContext) + .onStart { + emit(emptyList()) + }, + backupInProgressFlow, + restorationInProgressFlow, + deletionInProgressFlow, + ) { authState, backupEnabled, lastBackupDate, _, backupInProgress, restorationInProgress, deletionInProgress -> + return@combine when (authState) { + AuthState.NotAuthenticated -> State.NotAuthenticated + AuthState.Authenticating -> State.Authenticating + is AuthState.Authenticated -> { + if (backupInProgress) { + State.BackupInProgress(authState.currentUser) + } else if (restorationInProgress) { + State.RestorationInProgress(authState.currentUser) + } else if (deletionInProgress) { + State.DeletionInProgress(authState.currentUser) + } else { + if (backupEnabled) { + val backupNowAvailable = + lastBackupDate == null || lastBackupDate.isOlderThanADay() + val restoreAvailable = lastBackupDate != null - cloudBackupStateMutableFlow.value = computeBackupCloudStorageState(authState) + State.Activated( + authState.currentUser, + lastBackupDate, + backupNowAvailable, + restoreAvailable + ) + } else { + State.NotActivated(authState.currentUser) + } } - } else { - cloudBackupStateMutableFlow.value = computeBackupCloudStorageState(authState) - } - } - viewModelScope.launchCollect(getBackupJobInfosFlow(appContext)) { - cloudBackupStateMutableFlow.value = computeBackupCloudStorageState(auth.state.value) + } } } + .stateIn(viewModelScope, SharingStarted.Eagerly, State.NotAuthenticated) fun onAuthenticateButtonPressed() { viewModelScope.launch { - authenticationConfirmationDisplayEventMutableFlow.emit(Unit) + eventMutableFlow.emit(Event.ShowAuthenticationConfirmation) } } - fun onAuthenticationConfirmationConfirmed(activity: Activity) { - auth.startAuthentication(activity) + fun onAuthenticationConfirmationConfirmed(launcher: ManagedActivityResultLauncher) { + auth.startAuthentication(launcher) } fun onAuthenticationConfirmationCancelled() { // No-op } - fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - auth.handleActivityResult(requestCode, resultCode, data) + fun handleAuthActivityResult(activityResult: ActivityResult) { + auth.handleActivityResult(activityResult.resultCode, activityResult.data) } fun onLogoutButtonPressed() { @@ -140,45 +154,19 @@ class BackupSettingsViewModel @Inject constructor( auth.logout() } - private fun computeBackupCloudStorageState(authState: AuthState?): BackupCloudStorageState { - return when(authState) { - AuthState.NotAuthenticated -> BackupCloudStorageState.NotAuthenticated - AuthState.Authenticating -> BackupCloudStorageState.Authenticating - is AuthState.Authenticated -> { - if( backupInProgress ) { - BackupCloudStorageState.BackupInProgress(authState.currentUser) - } else if ( restorationInProgress ) { - BackupCloudStorageState.RestorationInProgress(authState.currentUser) - } else if ( deletionInProgress ) { - BackupCloudStorageState.DeletionInProgress(authState.currentUser) - } else { - if( parameters.isBackupEnabled() ) { - val lastBackupDate = parameters.getLastBackupDate() - val backupNowAvailable = lastBackupDate == null || lastBackupDate.isOlderThanADay() - val restoreAvailable = lastBackupDate != null - - BackupCloudStorageState.Activated(authState.currentUser, lastBackupDate, backupNowAvailable, restoreAvailable) - } else { - BackupCloudStorageState.NotActivated(authState.currentUser) - } - } - - } - null -> BackupCloudStorageState.NotAuthenticated - } - } - fun onBackupActivated() { - if( !parameters.isBackupEnabled() ) { + if (!parameters.isBackupEnabled()) { parameters.setBackupEnabled(true) - val newBackupState = computeBackupCloudStorageState(auth.state.value) - cloudBackupStateMutableFlow.value = newBackupState - if( newBackupState is BackupCloudStorageState.Activated ) { - val lastBackupDate = newBackupState.lastBackupDate - if( lastBackupDate != null ) { + viewModelScope.launch { + val maybeBackupActivatedState = withTimeoutOrNull(5.seconds) { + stateFlow.filterIsInstance().first() + } + + val lastBackupDate = maybeBackupActivatedState?.lastBackupDate + if (lastBackupDate != null) { viewModelScope.launch { - previousBackupAvailableEventMutableFlow.emit(lastBackupDate) + eventMutableFlow.emit(Event.PromptUserToRestorePreviousBackup(lastBackupDate)) } } } @@ -188,18 +176,15 @@ class BackupSettingsViewModel @Inject constructor( } fun onBackupDeactivated() { - if( parameters.isBackupEnabled() ) { + if (parameters.isBackupEnabled()) { parameters.setBackupEnabled(false) - cloudBackupStateMutableFlow.value = computeBackupCloudStorageState(auth.state.value) - unscheduleBackup(appContext) } } fun onBackupNowButtonPressed() { viewModelScope.launch { - backupInProgress = true - cloudBackupStateMutableFlow.value = computeBackupCloudStorageState(auth.state.value) + backupInProgressFlow.value = true try { withContext(Dispatchers.IO) { @@ -211,7 +196,7 @@ class BackupSettingsViewModel @Inject constructor( iab, ) - if( result !is ListenableWorker.Result.Success ) { + if (result !is ListenableWorker.Result.Success) { throw RuntimeException(result.toString()) } } @@ -219,10 +204,9 @@ class BackupSettingsViewModel @Inject constructor( if (error is CancellationException) throw error Logger.error("Error while backup now", error) - backupNowErrorEventMutableFlow.emit(error) + eventMutableFlow.emit(Event.ShowBackupNowError(error)) } finally { - backupInProgress = false - cloudBackupStateMutableFlow.value = computeBackupCloudStorageState(auth.state.value) + backupInProgressFlow.value = false } } } @@ -249,14 +233,13 @@ class BackupSettingsViewModel @Inject constructor( fun onDeleteBackupButtonPressed() { viewModelScope.launch { - deleteConfirmationDisplayEventMutableFlow.emit(Unit) + eventMutableFlow.emit(Event.ShowDeleteConfirmation) } } fun onDeleteBackupConfirmationConfirmed() { viewModelScope.launch { - deletionInProgress = true - cloudBackupStateMutableFlow.value = computeBackupCloudStorageState(auth.state.value) + deletionInProgressFlow.value = true try { withContext(Dispatchers.IO) { @@ -264,11 +247,12 @@ class BackupSettingsViewModel @Inject constructor( parameters.saveLastBackupDate(null) } } catch (error: Throwable) { + if (error is CancellationException) throw error + Logger.error("Error while deleting backup", error) - backupDeletionErrorEventMutableFlow.emit(error) + eventMutableFlow.emit(Event.ShowBackupDeletionError(error)) } finally { - deletionInProgress = false - cloudBackupStateMutableFlow.value = computeBackupCloudStorageState(auth.state.value) + deletionInProgressFlow.value = false } } } @@ -278,34 +262,37 @@ class BackupSettingsViewModel @Inject constructor( } private fun startRestoreFlow() { - val lastBackupDate = (cloudBackupStateMutableFlow.value as? BackupCloudStorageState.Activated)?.lastBackupDate - if( lastBackupDate == null ) { - Logger.error("Starting restore with no last backup date") + val lastBackupDate = (stateFlow.value as? State.Activated)?.lastBackupDate + if (lastBackupDate == null) { + Logger.error( + "Starting restore with no last backup date", + NullPointerException("No last backup date") + ) return } viewModelScope.launch { - restoreConfirmationDisplayEventMutableFlow.emit(lastBackupDate) + eventMutableFlow.emit(Event.ShowRestoreConfirmation(lastBackupDate)) } } private fun restoreData() { viewModelScope.launch { - restorationInProgress = true - cloudBackupStateMutableFlow.value = computeBackupCloudStorageState(auth.state.value) + restorationInProgressFlow.value = true try { withContext(Dispatchers.IO) { restoreLatestDBBackup(appContext, auth, cloudStorage, iab, parameters) } - appRestartEventMutableFlow.emit(Unit) + eventMutableFlow.emit(Event.RestartApp) } catch (error: Throwable) { + if (error is CancellationException) throw error + Logger.error("Error while restoring", error) - restorationErrorEventMutableFlow.emit(error) + eventMutableFlow.emit(Event.ShowRestoreError(error)) } finally { - restorationInProgress = false - cloudBackupStateMutableFlow.value = computeBackupCloudStorageState(auth.state.value) + restorationInProgressFlow.value = false } } } @@ -316,17 +303,36 @@ class BackupSettingsViewModel @Inject constructor( return calendar.time.after(this) } -} - -sealed class BackupCloudStorageState { - data object NotAuthenticated : BackupCloudStorageState() - data object Authenticating : BackupCloudStorageState() - data class NotActivated(val currentUser: CurrentUser) : BackupCloudStorageState() - data class Activated(val currentUser: CurrentUser, - val lastBackupDate: Date?, - val backupNowAvailable: Boolean, - val restoreAvailable: Boolean): BackupCloudStorageState() - data class BackupInProgress(val currentUser: CurrentUser): BackupCloudStorageState() - data class RestorationInProgress(val currentUser: CurrentUser): BackupCloudStorageState() - data class DeletionInProgress(val currentUser: CurrentUser): BackupCloudStorageState() -} + + sealed class Event { + data object ShowAuthenticationConfirmation : Event() + data class ShowBackupNowError(val error: Throwable) : Event() + data class ShowRestoreError(val error: Throwable) : Event() + data object RestartApp : Event() + data class ShowRestoreConfirmation(val lastBackupDate: Date) : Event() + data object ShowDeleteConfirmation : Event() + data class ShowBackupDeletionError(val error: Throwable) : Event() + data class PromptUserToRestorePreviousBackup(val lastBackupDate: Date) : Event() + } + + sealed class State { + data object NotAuthenticated : State() + data object Authenticating : State() + sealed interface Authenticated { + val currentUser: CurrentUser + } + + data class NotActivated(override val currentUser: CurrentUser) : State(), Authenticated + data class Activated( + override val currentUser: CurrentUser, + val lastBackupDate: Date?, + val backupNowAvailable: Boolean, + val restoreAvailable: Boolean + ) : State(), Authenticated + + data class BackupInProgress(override val currentUser: CurrentUser) : State(), Authenticated + data class RestorationInProgress(override val currentUser: CurrentUser) : State(), Authenticated + data class DeletionInProgress(override val currentUser: CurrentUser) : State(), Authenticated + } + +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/subviews/AuthenticatedView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/subviews/AuthenticatedView.kt new file mode 100644 index 00000000..5cad48ab --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/subviews/AuthenticatedView.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.settings.backup.subviews + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.compose.components.LoadingView +import com.benoitletondor.easybudgetapp.view.settings.backup.BackupSettingsViewModel + +@Composable +fun ColumnScope.AuthenticatedView( + state: BackupSettingsViewModel.State.Authenticated, + onLogoutButtonClicked: () -> Unit, + onBackupActivationChange: (Boolean) -> Unit, + onBackupNowClicked: () -> Unit, + onRestoreNowClicked: () -> Unit, + onDeleteBackupClicked: () -> Unit, +) { + val context = LocalContext.current + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.backup_settings_your_google_account), + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + ) + + Text( + modifier = Modifier.fillMaxWidth(), + text = state.currentUser.email, + fontSize = 16.sp, + ) + + when(state) { + is BackupSettingsViewModel.State.Activated, + is BackupSettingsViewModel.State.NotActivated -> { + TextButton( + modifier = Modifier.align(Alignment.End), + onClick = onLogoutButtonClicked, + ) { + Text(text = stringResource(R.string.backup_settings_logout_cta)) + } + + Spacer(modifier = Modifier.height(30.dp)) + + when(state) { + is BackupSettingsViewModel.State.Activated -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.backup_settings_cloud_backup_status, stringResource(R.string.backup_settings_cloud_backup_status_activated)), + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Switch( + checked = true, + onCheckedChange = onBackupActivationChange, + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.backup_activated_description), + fontSize = 15.sp, + color = colorResource(R.color.secondary_text), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val lastBackUpDateText = remember(state.lastBackupDate) { + if( state.lastBackupDate != null ) { + val timeFormatted = DateUtils.getRelativeDateTimeString( + context, + state.lastBackupDate.time, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + DateUtils.FORMAT_SHOW_TIME + ) + + context.getString(R.string.backup_last_update_date, timeFormatted) + } else { + context.getString(R.string.backup_last_update_date, context.getString(R.string.backup_last_update_date_never)) + } + } + + Text( + modifier = Modifier.fillMaxWidth(), + text = lastBackUpDateText, + fontSize = 16.sp, + ) + + if (state.backupNowAvailable) { + Spacer(modifier = Modifier.height(10.dp)) + + Button( + onClick = onBackupNowClicked, + ) { + Text(text = stringResource(R.string.backup_now_cta)) + } + } + + if (state.restoreAvailable) { + Spacer(modifier = Modifier.height(40.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.backup_restore_description), + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.backup_restore_explanation), + fontSize = 15.sp, + color = colorResource(R.color.secondary_text), + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Button( + onClick = onRestoreNowClicked, + ) { + Text(text = stringResource(R.string.backup_restore_cta)) + } + + Spacer(modifier = Modifier.height(40.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.backup_wipe_data_title), + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.backup_wipe_data_description), + fontSize = 15.sp, + color = colorResource(R.color.secondary_text), + ) + + Spacer(modifier = Modifier.height(10.dp)) + + TextButton( + onClick = onDeleteBackupClicked, + colors = ButtonDefaults.textButtonColors( + contentColor = colorResource(R.color.budget_red) + ), + ) { + Text(text = stringResource(R.string.backup_wipe_data_cta)) + } + } + } + is BackupSettingsViewModel.State.NotActivated -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.backup_settings_cloud_backup_status, stringResource(R.string.backup_settings_cloud_backup_status_disabled)), + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Switch( + checked = false, + onCheckedChange = onBackupActivationChange, + ) + } + } + else -> {} + } + } + is BackupSettingsViewModel.State.BackupInProgress -> LoadingView() + is BackupSettingsViewModel.State.DeletionInProgress -> LoadingView() + is BackupSettingsViewModel.State.RestorationInProgress -> LoadingView() + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/subviews/NotAuthenticatedView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/subviews/NotAuthenticatedView.kt new file mode 100644 index 00000000..8896d61b --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/backup/subviews/NotAuthenticatedView.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.settings.backup.subviews + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@Composable +fun ColumnScope.NotAuthenticatedView( + onLoginButtonClicked: () -> Unit, +) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.backup_settings_not_authenticated_description), + fontSize = 16.sp, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onLoginButtonClicked + ) { + Text(text = stringResource(R.string.backup_settings_authenticate_cta)) + } + + Spacer(modifier = Modifier.height(30.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.backup_settings_not_authenticated_description_2), + fontSize = 16.sp, + color = colorResource(R.color.secondary_text), + ) +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/ErrorView.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/ErrorView.kt new file mode 100644 index 00000000..63253f56 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/ErrorView.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.settings.subviews + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@Composable +fun ErrorView( + contentPadding: PaddingValues, + error: Throwable, + onRetryButtonClicked: () -> Unit, +) { + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.settings_error_loading_title), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.settings_error_loading_description, error.localizedMessage ?: "No error message"), + fontSize = 16.sp, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Button( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = onRetryButtonClicked, + ) { + Text(stringResource(R.string.manage_account_error_cta)) + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/LowMoneyWarningAmountPicker.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/LowMoneyWarningAmountPicker.kt new file mode 100644 index 00000000..de4f553b --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/LowMoneyWarningAmountPicker.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.settings.subviews + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.EditText +import com.benoitletondor.easybudgetapp.R +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +fun Context.showLowMoneyWarningAmountPickerDialog( + lowMoneyWarningAmount: Int, + onLowMoneyWarningAmountChanged: (Int) -> Unit, +) { + val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_set_warning_limit, null) + val limitEditText = dialogView?.findViewById(R.id.warning_limit) as EditText + limitEditText.setText(lowMoneyWarningAmount.toString()) + limitEditText.setSelection(limitEditText.text.length) // Put focus at the end of the text + + val builder = MaterialAlertDialogBuilder(this) + builder.setTitle(R.string.adjust_limit_warning_title) + builder.setMessage(R.string.adjust_limit_warning_message) + builder.setView(dialogView) + builder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + builder.setPositiveButton(R.string.ok) { _, _ -> + var limitString = limitEditText.text.toString() + if (limitString.trim { it <= ' ' }.isEmpty()) { + limitString = "0" // Set a 0 value if no value is provided (will lead to an error displayed to the user) + } + + try { + val newLimit = Integer.valueOf(limitString) + + // Invalid value, alert the user + if (newLimit <= 0) { + throw IllegalArgumentException("limit should be > 0") + } + + onLowMoneyWarningAmountChanged(newLimit) + } catch (e: Exception) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.adjust_limit_warning_error_title) + .setMessage(resources.getString(R.string.adjust_limit_warning_error_message)) + .setPositiveButton(R.string.ok) { dialog1, _ -> dialog1.dismiss() } + .show() + } + } + + val dialog = builder.show() + + // Directly show keyboard when the dialog pops + limitEditText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + } + } + limitEditText.requestFocus() +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/RedeemCodeDialog.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/RedeemCodeDialog.kt new file mode 100644 index 00000000..36933d63 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/RedeemCodeDialog.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.settings.subviews + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.EditText +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.helper.Logger +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.net.URLEncoder + +fun Context.openRedeemCodeDialog() { + val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_redeem_voucher, null) + val voucherEditText = dialogView.findViewById(R.id.voucher) as EditText + + val builder = MaterialAlertDialogBuilder(this) + .setTitle(R.string.voucher_redeem_dialog_title) + .setMessage(R.string.voucher_redeem_dialog_message) + .setView(dialogView) + .setPositiveButton(R.string.voucher_redeem_dialog_cta) { dialog, _ -> + dialog.dismiss() + + val promocode = voucherEditText.text.toString() + if (promocode.trim { it <= ' ' }.isEmpty()) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.voucher_redeem_error_dialog_title) + .setMessage(R.string.voucher_redeem_error_code_invalid_dialog_message) + .setPositiveButton(R.string.ok) { dialog12, _ -> dialog12.dismiss() } + .show() + + return@setPositiveButton + } + + try { + val url = "https://play.google.com/redeem?code=" + URLEncoder.encode(promocode, "UTF-8") + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } catch (e: Exception) { + Logger.error("Error while redeeming promocode", e) + MaterialAlertDialogBuilder(this) + .setTitle(R.string.iab_purchase_error_title) + .setMessage(resources.getString(R.string.iab_purchase_error_message, "Error redeeming promo code")) + .setPositiveButton(R.string.ok) { dialog1, _ -> dialog1.dismiss() } + .show() + } + } + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + + val dialog = builder.show() + + // Directly show keyboard when the dialog pops + voucherEditText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> + // Check if the device doesn't have a physical keyboard + if (hasFocus) { + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + } + } + voucherEditText.requestFocus() +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/Settings.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/Settings.kt new file mode 100644 index 00000000..69349f47 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/Settings.kt @@ -0,0 +1,275 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.settings.subviews + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.helper.AppTheme.* +import com.benoitletondor.easybudgetapp.helper.CurrencyHelper +import com.benoitletondor.easybudgetapp.view.settings.SettingsViewModel +import java.time.DayOfWeek + +@Composable +fun Settings( + contentPadding: PaddingValues, + state: SettingsViewModel.State.Loaded, + onCurrencyChangeClicked: () -> Unit, + onAdjustLowMoneyWarningAmountClicked: () -> Unit, + onFirstDayOfWeekChanged: (DayOfWeek) -> Unit, + onPremiumButtonClicked: () -> Unit, + onProButtonClicked: () -> Unit, + onThemeClicked: () -> Unit, + onShowCheckedBalanceChanged: (Boolean) -> Unit, + onCloudBackupClicked: () -> Unit, + onDailyReminderNotificationActivatedChanged: (Boolean) -> Unit, + onMonthlyReportNotificationActivatedChanged: (Boolean) -> Unit, + onRateAppClicked: () -> Unit, + onShareAppClicked: () -> Unit, + onUpdateNotificationActivatedChanged: (Boolean) -> Unit, + onBugReportClicked: () -> Unit, + onAppClicked: () -> Unit, + onSubscribeButtonClicked: () -> Unit, + onRedeemCodeButtonClicked: () -> Unit, +) { + LazyColumn( + modifier = Modifier + .consumeWindowInsets(contentPadding) + .fillMaxSize(), + contentPadding = contentPadding, + ) { + item(key = "generalCategory") { + SettingsCategoryTitle(title = stringResource(R.string.setting_category_general_title)) + } + + item(key = "currencyChangeButton") { + SettingsButton( + title = stringResource(R.string.setting_category_currency_change_button_title, state.userCurrency.symbol), + subtitle = stringResource(R.string.setting_category_currency_change_button_message), + onClick = onCurrencyChangeClicked, + ) + } + + item(key = "lowMoneyWarningAmountButton") { + SettingsButton( + title = stringResource(R.string.setting_category_limit_set_button_title, CurrencyHelper.getFormattedCurrencyString(state.userCurrency, state.lowMoneyWarningAmount.toDouble())), + subtitle = stringResource(R.string.setting_category_limit_set_button_message), + onClick = onAdjustLowMoneyWarningAmountClicked, + ) + } + + item(key = "startDayOfWeekSwitch") { + SettingsSwitch( + title = stringResource(R.string.setting_category_start_day_of_week_title), + subtitle = stringResource(if (state.firstDayOfWeek == DayOfWeek.SUNDAY) R.string.setting_category_start_day_of_week_sunday else R.string.setting_category_start_day_of_week_monday), + checked = state.firstDayOfWeek == DayOfWeek.SUNDAY, + onCheckedChanged = { checked -> + if (checked) { + onFirstDayOfWeekChanged(DayOfWeek.SUNDAY) + } else { + onFirstDayOfWeekChanged(DayOfWeek.MONDAY) + } + } + ) + } + + when(val subscriptionStatus = state.subscriptionStatus) { + SettingsViewModel.SubscriptionStatus.Error -> { + item(key = "notSubscribedCategory") { + SettingsCategoryTitle(title = stringResource(R.string.setting_category_not_premium_title)) + } + + item(key = "subscriptionError") { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + text = stringResource(R.string.premium_screen_error_loading_message), + color = colorResource(R.color.primary_text), + fontSize = 16.sp, + ) + } + } + SettingsViewModel.SubscriptionStatus.Loading -> { + item(key = "notSubscribedCategory") { + SettingsCategoryTitle(title = stringResource(R.string.setting_category_not_premium_title)) + } + + item(key = "subscriptionLoading") { + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + SettingsViewModel.SubscriptionStatus.NotSubscribed -> { + item(key = "notSubscribedCategory") { + SettingsCategoryTitle(title = stringResource(R.string.setting_category_not_premium_title)) + } + + item(key = "subscribeButton") { + SettingsButton( + title = stringResource(R.string.setting_category_not_premium_status_title), + subtitle = stringResource(R.string.setting_category_not_premium_status_message), + onClick = onSubscribeButtonClicked, + ) + } + + item(key = "redeemCode") { + SettingsButton( + title = stringResource(R.string.setting_category_premium_redeem_title), + subtitle = stringResource(R.string.setting_category_premium_redeem_message), + onClick = onRedeemCodeButtonClicked, + ) + } + } + is SettingsViewModel.SubscriptionStatus.Subscribed -> { + item(key = "subscriptionCategory") { + SettingsCategoryTitle(title = stringResource(R.string.setting_category_premium_title)) + } + + when(subscriptionStatus) { + is SettingsViewModel.SubscriptionStatus.PremiumSubscribed -> { + item(key = "premiumSubscribed") { + SettingsButton( + title = stringResource(R.string.setting_category_premium_status_title), + subtitle = stringResource(R.string.setting_category_premium_status_message), + onClick = onPremiumButtonClicked, + ) + } + } + is SettingsViewModel.SubscriptionStatus.ProSubscribed -> { + item(key = "proSubscribed") { + SettingsButton( + title = stringResource(R.string.setting_category_pro_status_title), + subtitle = stringResource(R.string.setting_category_pro_status_message), + onClick = onProButtonClicked, + ) + } + } + } + + item(key = "theme") { + SettingsButton( + title = stringResource(R.string.setting_category_theme_title), + subtitle = stringResource(when(subscriptionStatus.theme) { + LIGHT -> R.string.setting_theme_light + DARK -> R.string.setting_theme_dark + PLATFORM_DEFAULT -> R.string.setting_theme_platform + }), + onClick = onThemeClicked, + ) + } + + item(key = "checkedBalance") { + SettingsCheckbox( + title = stringResource(R.string.setting_category_show_checked_balance_title), + subtitle = stringResource(R.string.setting_category_show_checked_balance_message), + checked = subscriptionStatus.showCheckedBalance, + onCheckedChanged = onShowCheckedBalanceChanged, + ) + } + + item(key = "cloudBackup") { + SettingsButton( + title = stringResource(R.string.backup_settings_activity_title), + subtitle = stringResource(if (subscriptionStatus.cloudBackupEnabled) R.string.backup_settings_backups_activated else R.string.backup_settings_backups_deactivated), + onClick = onCloudBackupClicked + ) + } + + item(key = "dailyReminderNotification") { + SettingsCheckbox( + title = stringResource(R.string.setting_category_notifications_daily_title), + subtitle = stringResource(R.string.setting_category_notifications_daily_message), + checked = subscriptionStatus.dailyReminderActivated, + onCheckedChanged = onDailyReminderNotificationActivatedChanged, + ) + } + + item(key = "monthlyReportNotification") { + SettingsCheckbox( + title = stringResource(R.string.setting_category_notifications_monthly_title), + subtitle = stringResource(R.string.setting_category_notifications_monthly_message), + checked = subscriptionStatus.monthlyReportNotificationActivated, + onCheckedChanged = onMonthlyReportNotificationActivatedChanged, + ) + } + } + } + + item(key = "appCategory") { + SettingsCategoryTitle(title = stringResource(R.string.setting_category_app_title)) + } + + item(key = "rating") { + SettingsButton( + title = stringResource(R.string.setting_category_rate_button_title), + subtitle = stringResource(R.string.setting_category_rate_button_message), + onClick = onRateAppClicked, + ) + } + + item(key = "share") { + SettingsButton( + title = stringResource(R.string.setting_category_share_app_title), + subtitle = stringResource(R.string.setting_category_share_app_message), + onClick = onShareAppClicked, + ) + } + + item(key = "updatesNotifications") { + SettingsCheckbox( + title = stringResource(R.string.setting_category_notifications_update_title), + subtitle = stringResource(R.string.setting_category_notifications_update_message), + checked = state.userAllowingUpdatePushes, + onCheckedChanged = onUpdateNotificationActivatedChanged, + ) + } + + item(key = "bugReport") { + SettingsButton( + title = stringResource(R.string.setting_category_bug_report_send_button_title), + onClick = onBugReportClicked, + ) + } + + item(key = "app") { + SettingsButton( + title = stringResource(R.string.setting_category_app_version_title, state.appVersion), + subtitle = stringResource(R.string.setting_category_app_version_message), + onClick = onAppClicked, + ) + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsButton.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsButton.kt new file mode 100644 index 00000000..04af17e1 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsButton.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.settings.subviews + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@Composable +fun SettingsButton( + title: String, + subtitle: String? = null, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = colorResource(R.color.primary_text), + ) + + if (subtitle != null) { + Text( + text = subtitle, + fontSize = 15.sp, + color = colorResource(R.color.primary_text), + ) + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsCategoryTitle.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsCategoryTitle.kt new file mode 100644 index 00000000..7fbc6fc5 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsCategoryTitle.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.settings.subviews + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@Composable +fun SettingsCategoryTitle( + title: String +) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(top = 20.dp, bottom = 12.dp), + text = title, + fontSize = 19.sp, + color = colorResource(R.color.primary), + ) +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsCheckbox.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsCheckbox.kt new file mode 100644 index 00000000..e19d3d29 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsCheckbox.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.settings.subviews + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@Composable +fun SettingsCheckbox( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChanged: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = { + onCheckedChanged(!checked) + }) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ){ + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = colorResource(R.color.primary_text), + ) + + Text( + text = subtitle, + fontSize = 15.sp, + color = colorResource(R.color.primary_text), + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Checkbox( + checked = checked, + onCheckedChange = onCheckedChanged, + ) + } + +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsSwitch.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsSwitch.kt new file mode 100644 index 00000000..37bf4fa5 --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/SettingsSwitch.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.settings.subviews + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.benoitletondor.easybudgetapp.R + +@Composable +fun SettingsSwitch( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChanged: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = { + onCheckedChanged(!checked) + }) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ){ + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = colorResource(R.color.primary_text), + ) + + Text( + text = subtitle, + fontSize = 15.sp, + color = colorResource(R.color.primary_text), + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Switch( + checked = checked, + onCheckedChange = onCheckedChanged, + ) + } + +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/ThemePickerDialog.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/ThemePickerDialog.kt new file mode 100644 index 00000000..cc6abe2f --- /dev/null +++ b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/settings/subviews/ThemePickerDialog.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2024 Benoit LETONDOR + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.benoitletondor.easybudgetapp.view.settings.subviews + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.benoitletondor.easybudgetapp.R +import com.benoitletondor.easybudgetapp.helper.AppTheme + +@Composable +fun ThemePickerDialog( + contentPadding: PaddingValues, + currentTheme: AppTheme, + onThemeSelected: (AppTheme) -> Unit, + onDismissRequest: () -> Unit, +) { + Dialog(onDismissRequest = onDismissRequest) { + Card( + modifier = Modifier + .padding(contentPadding) + .fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.window_background), + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp) + .padding(top = 14.dp, bottom = 10.dp), + text = stringResource(R.string.setting_category_theme_title), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = colorResource(R.color.primary_text), + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onThemeSelected(AppTheme.LIGHT) } + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ){ + RadioButton( + selected = currentTheme == AppTheme.LIGHT, + onClick = { + onThemeSelected(AppTheme.LIGHT) + }, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.setting_theme_light), + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onThemeSelected(AppTheme.DARK) } + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ){ + RadioButton( + selected = currentTheme == AppTheme.DARK, + onClick = { + onThemeSelected(AppTheme.DARK) + }, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.setting_theme_dark), + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onThemeSelected(AppTheme.PLATFORM_DEFAULT) } + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ){ + RadioButton( + selected = currentTheme == AppTheme.PLATFORM_DEFAULT, + onClick = { + onThemeSelected(AppTheme.PLATFORM_DEFAULT) + }, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.setting_theme_platform), + ) + } + } + } + } +} \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding1Fragment.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding1Fragment.kt deleted file mode 100644 index e093150f..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding1Fragment.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.welcome - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup - -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.databinding.FragmentOnboarding1Binding -import dagger.hilt.android.AndroidEntryPoint - -/** - * Onboarding step 1 fragment - * - * @author Benoit LETONDOR - */ -@AndroidEntryPoint -class Onboarding1Fragment : OnboardingFragment() { - - override val statusBarColor: Int - get() = R.color.primary_dark - - override fun onCreateBinding( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): FragmentOnboarding1Binding = FragmentOnboarding1Binding.inflate(inflater, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding?.onboardingScreen1NextButton?.setOnClickListener { button -> - next(button) - } - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding2Fragment.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding2Fragment.kt deleted file mode 100644 index fbc11deb..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding2Fragment.kt +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.welcome - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.databinding.FragmentOnboarding2Binding -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.benoitletondor.easybudgetapp.helper.getUserCurrency -import com.benoitletondor.easybudgetapp.view.selectcurrency.SelectCurrencyFragment -import dagger.hilt.android.AndroidEntryPoint -import java.util.* -import javax.inject.Inject - -/** - * Onboarding step 2 fragment - * - * @author Benoit LETONDOR - */ -@AndroidEntryPoint -class Onboarding2Fragment : OnboardingFragment() { - private lateinit var selectedCurrency: Currency - private lateinit var receiver: BroadcastReceiver - - @Inject lateinit var parameters: Parameters - - override val statusBarColor: Int - get() = R.color.secondary_dark - -// -------------------------------------> - - override fun onCreateBinding( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): FragmentOnboarding2Binding = FragmentOnboarding2Binding.inflate(inflater, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - selectedCurrency = parameters.getUserCurrency() - setNextButtonText() - - val selectCurrencyFragment = SelectCurrencyFragment() - val transaction = childFragmentManager.beginTransaction() - transaction.add(R.id.expense_select_container, selectCurrencyFragment).commit() - - val filter = IntentFilter(SelectCurrencyFragment.CURRENCY_SELECTED_INTENT) - receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - selectedCurrency = Currency.getInstance(intent.getStringExtra(SelectCurrencyFragment.CURRENCY_ISO_EXTRA)) - setNextButtonText() - } - } - - LocalBroadcastManager.getInstance(view.context).registerReceiver(receiver, filter) - - binding?.onboardingScreen2NextButton?.setOnClickListener { - next() - } - } - - override fun onDestroyView() { - LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(receiver) - - super.onDestroyView() - } - - /** - * Set the next button text according to the selected currency - */ - private fun setNextButtonText() { - binding?.onboardingScreen2NextButton?.text = resources.getString(R.string.onboarding_screen_2_cta, selectedCurrency.symbol) - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding3Fragment.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding3Fragment.kt deleted file mode 100644 index 400dcc76..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding3Fragment.kt +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.welcome - -import android.content.Context -import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.databinding.FragmentOnboarding3Binding -import com.benoitletondor.easybudgetapp.db.DB -import com.benoitletondor.easybudgetapp.helper.* -import com.benoitletondor.easybudgetapp.model.Expense -import com.benoitletondor.easybudgetapp.parameters.Parameters -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.* -import java.time.LocalDate -import javax.inject.Inject - -/** - * Onboarding step 3 fragment - * - * @author Benoit LETONDOR - */ -@AndroidEntryPoint -class Onboarding3Fragment : OnboardingFragment() { - @Inject lateinit var parameters: Parameters - @Inject lateinit var db: DB - - override val statusBarColor: Int - get() = R.color.secondary_dark - - private val amountValue: Double - get() { - val valueString = binding?.onboardingScreen3InitialAmountEt?.text.toString() - - return try { - if ( "" == valueString || "-" == valueString) 0.0 else java.lang.Double.valueOf(valueString) - } catch (e: Exception) { - val context = context ?: return 0.0 - - MaterialAlertDialogBuilder(context) - .setTitle(R.string.adjust_balance_error_title) - .setMessage(R.string.adjust_balance_error_message) - .setNegativeButton(R.string.ok) { dialog, _ -> dialog.dismiss() } - .show() - - Logger.warning("An error occurred during initial amount parsing: $valueString", e) - return 0.0 - } - - } - - override fun onCreateBinding( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): FragmentOnboarding3Binding = FragmentOnboarding3Binding.inflate(inflater, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewLifecycleScope.launch { - val amount = withContext(Dispatchers.Default) { - -db.getBalanceForDay(LocalDate.now()) - } - - binding?.onboardingScreen3InitialAmountEt?.setText(if (amount == 0.0) "0" else amount.toString()) - } - - setCurrency() - - binding?.onboardingScreen3InitialAmountEt?.preventUnsupportedInputForDecimals() - binding?.onboardingScreen3InitialAmountEt?.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { - - } - - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { - - } - - override fun afterTextChanged(s: Editable) { - setButtonText() - } - }) - - binding?.onboardingScreen3NextButton?.setOnClickListener { button -> - viewLifecycleScope.launch { - withContext(Dispatchers.Default) { - val currentBalance = -db.getBalanceForDay(LocalDate.now()) - val newBalance = amountValue - - if (newBalance != currentBalance) { - val diff = newBalance - currentBalance - - val expense = Expense(resources.getString(R.string.adjust_balance_expense_title), -diff, LocalDate.now(), true) - db.persistExpense(expense) - } - } - - // Hide keyboard - try { - val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.hideSoftInputFromWindow(binding?.onboardingScreen3InitialAmountEt?.windowToken, 0) - } catch (e: Exception) { - Logger.error("Error while hiding keyboard", e) - } - - next(button) - } - } - - setButtonText() - } - - override fun onResume() { - super.onResume() - - setCurrency() - setButtonText() - } - -// --------------------------------------> - - private fun setCurrency() { - binding?.onboardingScreen3InitialAmountMoneyTv?.text = parameters.getUserCurrency().symbol - } - - private fun setButtonText() { - binding?.onboardingScreen3NextButton?.text = getString(R.string.onboarding_screen_3_cta, CurrencyHelper.getFormattedCurrencyString(parameters, amountValue)) - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding4Fragment.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding4Fragment.kt deleted file mode 100644 index 145528e7..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/Onboarding4Fragment.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.welcome - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup - -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.databinding.FragmentOnboarding4Binding -import dagger.hilt.android.AndroidEntryPoint - -/** - * Onboarding step 4 fragment - * - * @author Benoit LETONDOR - */ -@AndroidEntryPoint -class Onboarding4Fragment : OnboardingFragment() { - - override val statusBarColor: Int - get() = R.color.primary_dark - - override fun onCreateBinding( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): FragmentOnboarding4Binding = FragmentOnboarding4Binding.inflate(inflater, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding?.onboardingScreen4NextButton?.setOnClickListener { - done() - } - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/OnboardingFragment.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/OnboardingFragment.kt deleted file mode 100644 index fa89a1ff..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/OnboardingFragment.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.welcome - -import android.content.Intent -import android.view.View -import androidx.annotation.ColorRes -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.viewbinding.ViewBinding -import com.benoitletondor.easybudgetapp.helper.BaseFragment - -/** - * Abstract fragment that contains common methods of all onboarding fragments - * - * @author Benoit LETONDOR - */ -abstract class OnboardingFragment : BaseFragment() { - /** - * Get the status bar color that should be used for this fragment - * - * @return the wanted color of the status bar - */ - @get:ColorRes - abstract val statusBarColor: Int - - /** - * Go to the next onboarding step without animation - */ - protected operator fun next() { - val intent = Intent(WelcomeActivity.PAGER_NEXT_INTENT) - LocalBroadcastManager.getInstance(requireContext()).sendBroadcast(intent) - } - - /** - * Go to the next onboarding step with a reveal animation starting from the given center - * - * @param animationCenter center of the reveal animation - */ - protected fun next(animationCenter: View) { - val intent = Intent(WelcomeActivity.PAGER_NEXT_INTENT) - intent.putExtra(WelcomeActivity.ANIMATE_TRANSITION_KEY, true) - intent.putExtra(WelcomeActivity.CENTER_X_KEY, animationCenter.x.toInt() + animationCenter.width / 2) - intent.putExtra(WelcomeActivity.CENTER_Y_KEY, animationCenter.y.toInt() + animationCenter.height / 2) - LocalBroadcastManager.getInstance(requireContext()).sendBroadcast(intent) - } - - /** - * Finish the onboarding flow - */ - protected fun done() { - val intent = Intent(WelcomeActivity.PAGER_DONE_INTENT) - LocalBroadcastManager.getInstance(requireContext()).sendBroadcast(intent) - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/OnboardingPushPermissionFragment.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/OnboardingPushPermissionFragment.kt deleted file mode 100644 index c0ff581e..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/OnboardingPushPermissionFragment.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.welcome - -import android.Manifest -import android.content.pm.PackageManager -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.core.content.ContextCompat - -import com.benoitletondor.easybudgetapp.R -import com.benoitletondor.easybudgetapp.databinding.FragmentOnboardingPushPermissionBinding -import dagger.hilt.android.AndroidEntryPoint - -/** - * Onboarding step push permission fragment - * - * @author Benoit LETONDOR - */ -@AndroidEntryPoint -class OnboardingPushPermissionFragment : OnboardingFragment() { - - override val statusBarColor: Int - get() = R.color.primary_dark - - private lateinit var requestPermissionLauncher: ActivityResultLauncher - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { - next() - } - } - - override fun onCreateBinding( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): FragmentOnboardingPushPermissionBinding = FragmentOnboardingPushPermissionBinding.inflate(inflater, container, false) - - @RequiresApi(33) - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding?.onboardingScreenPushPermissionAcceptButton?.setOnClickListener { button -> - if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } else { - next(button) - } - } - - binding?.onboardingScreenPushPermissionRefuseButton?.setOnClickListener { button -> - next(button) - } - } -} diff --git a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/WelcomeActivity.kt b/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/WelcomeActivity.kt deleted file mode 100644 index 68cb17dc..00000000 --- a/Android/EasyBudget/app/src/main/java/com/benoitletondor/easybudgetapp/view/welcome/WelcomeActivity.kt +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright 2024 Benoit LETONDOR - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.benoitletondor.easybudgetapp.view.welcome - -import android.app.Activity -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.Build -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentStatePagerAdapter -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.viewpager.widget.ViewPager -import android.os.Bundle -import android.view.View -import android.view.ViewAnimationUtils - -import com.benoitletondor.easybudgetapp.databinding.ActivityWelcomeBinding -import com.benoitletondor.easybudgetapp.helper.BaseActivity -import com.benoitletondor.easybudgetapp.helper.setNavigationBarColored -import com.benoitletondor.easybudgetapp.helper.setStatusBarColor -import com.benoitletondor.easybudgetapp.parameters.Parameters -import dagger.hilt.android.AndroidEntryPoint - -import java.lang.IllegalStateException -import javax.inject.Inject -import kotlin.math.max - -/** - * Welcome screen activity - * - * @author Benoit LETONDOR - */ -@AndroidEntryPoint -class WelcomeActivity : BaseActivity() { - /** - * Broadcast receiver for intent sent by fragments - */ - private lateinit var receiver: BroadcastReceiver - - @Inject lateinit var parameters: Parameters - -// ------------------------------------> - - private var step: Int - get() = parameters.getOnboardingStep() - set(step) = parameters.setOnboardingStep(step) - -// ------------------------------------------> - - override fun createBinding(): ActivityWelcomeBinding = ActivityWelcomeBinding.inflate(layoutInflater) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val isAndroid13OrMore = Build.VERSION.SDK_INT >= 33 - - // Reinit step to 0 if already completed - if (step == STEP_COMPLETED) { - step = 0 - } - - binding.welcomeViewPager.adapter = object : FragmentStatePagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { - override fun getItem(position: Int): Fragment { - if (isAndroid13OrMore) { - when (position) { - 0 -> return Onboarding1Fragment() - 1 -> return Onboarding2Fragment() - 2 -> return Onboarding3Fragment() - 3 -> return OnboardingPushPermissionFragment() - 4 -> return Onboarding4Fragment() - } - } else { - when (position) { - 0 -> return Onboarding1Fragment() - 1 -> return Onboarding2Fragment() - 2 -> return Onboarding3Fragment() - 3 -> return Onboarding4Fragment() - } - } - throw IllegalStateException("unknown position $position") - } - - override fun getCount(): Int = if (isAndroid13OrMore) 5 else 4 - } - binding.welcomeViewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener { - override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {} - - override fun onPageSelected(position: Int) { - ((binding.welcomeViewPager.adapter as? FragmentStatePagerAdapter)?.getItem(position) as? OnboardingFragment<*>)?.let { fragment -> - setStatusBarColor(fragment.statusBarColor) - } - - step = position - } - - override fun onPageScrollStateChanged(state: Int) {} - }) - binding.welcomeViewPager.offscreenPageLimit = binding.welcomeViewPager.adapter?.count ?: 0 // preload all fragments for transitions smoothness - - // Circle indicator - binding.welcomeViewPagerIndicator.setViewPager(binding.welcomeViewPager) - - val filter = IntentFilter() - filter.addAction(PAGER_NEXT_INTENT) - filter.addAction(PAGER_PREVIOUS_INTENT) - filter.addAction(PAGER_DONE_INTENT) - - receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val pager = binding.welcomeViewPager - val pagerAdapter = pager.adapter ?: return - - if (PAGER_NEXT_INTENT == intent.action && pager.currentItem < pagerAdapter.count - 1) { - if (intent.getBooleanExtra(ANIMATE_TRANSITION_KEY, false)) { - // get the center for the clipping circle - val cx = intent.getIntExtra(CENTER_X_KEY, pager.x.toInt() + pager.width / 2) - val cy = intent.getIntExtra(CENTER_Y_KEY, pager.y.toInt() + pager.height / 2) - - // get the final radius for the clipping circle - val finalRadius = max(pager.width, pager.height) - - // create the animator for this view (the start radius is zero) - val anim = ViewAnimationUtils.createCircularReveal(pager, cx, cy, 0f, finalRadius.toFloat()) - - // make the view visible and start the animation - pager.setCurrentItem(pager.currentItem + 1, false) - anim.start() - } else { - pager.setCurrentItem(pager.currentItem + 1, true) - } - } else if (PAGER_PREVIOUS_INTENT == intent.action && pager.currentItem > 0) { - pager.setCurrentItem(pager.currentItem - 1, true) - } else if (PAGER_DONE_INTENT == intent.action) { - step = STEP_COMPLETED - this@WelcomeActivity.setResult(Activity.RESULT_OK) - finish() - } - } - } - - LocalBroadcastManager.getInstance(this).registerReceiver(receiver, filter) - - val initialStep = step - - // Init pager at the current step - binding.welcomeViewPager.setCurrentItem(initialStep, false) - - // Set status bar color - (((binding.welcomeViewPager.adapter) as? FragmentStatePagerAdapter)?.getItem(initialStep) as? OnboardingFragment<*>)?.let { fragment -> - setStatusBarColor(fragment.statusBarColor) - } - - setNavigationBarColored() - } - - override fun onDestroy() { - LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) - - super.onDestroy() - } - - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - if (binding.welcomeViewPager.currentItem > 0) { - binding.welcomeViewPager.setCurrentItem(binding.welcomeViewPager.currentItem - 1, true) - return - } - - setResult(Activity.RESULT_CANCELED) - finish() - } - - companion object { - const val STEP_COMPLETED = Integer.MAX_VALUE - - const val ANIMATE_TRANSITION_KEY = "animate" - const val CENTER_X_KEY = "centerX" - const val CENTER_Y_KEY = "centerY" - - /** - * Intent broadcasted by pager fragments to go next - */ - const val PAGER_NEXT_INTENT = "welcome.pager.next" - /** - * Intent broadcasted by pager fragments to go previous - */ - const val PAGER_PREVIOUS_INTENT = "welcome.pager.previous" - /** - * Intent broadcasted by pager fragments when welcome onboarding is done - */ - const val PAGER_DONE_INTENT = "welcome.pager.done" - } -} - -/** - * The current onboarding step (int) - */ -private const val ONBOARDING_STEP_PARAMETERS_KEY = "onboarding_step" - -fun Parameters.getOnboardingStep(): Int { - return getInt(ONBOARDING_STEP_PARAMETERS_KEY, 0) -} - -private fun Parameters.setOnboardingStep(step: Int) { - putInt(ONBOARDING_STEP_PARAMETERS_KEY, step) -} diff --git a/Android/EasyBudget/app/src/main/res/drawable-hdpi/ic_launcher.png b/Android/EasyBudget/app/src/main/res/drawable-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..29dd1574a82cc689ae3b6fc0404389a103b63b54 GIT binary patch literal 3436 zcmV-y4U_VTP)D+13_f5rWlc~K2}vy9v1yhjZeOBVx|Wotex{;m3J3!80tA@{Bbv&ZZ4d2X z=9;x;>mw!IT=gs6P0QULKC`VgQS>uNLSZ<2{^#5~510!x!@VQ5_xt|CFmvy@=l{F^ z*E#px0l1YLfgmOY1uBmse*b^7p|Wo$sj2cB_Sf6Z^_X8BFm4SR_Se%^n%Wjt6m)@= z#-6Y|w>J}fkDuXp{@wU1)E_s6dQ9Wh>c(~9;R}ZN(KGwI+YuO(D#N4 z;k(0pUqd!K7Ta)#<(oCO0*leOxq;ab4z}Dmu-y1QRkoZePq-L-6yZ2*!)Dmd+Qhb9 z-M8TdX@wT!H2D(!aH`A(Dpwmou|u)Gj*FoVtoT2Z`UYYbzN>TDzupZPwvFe&bGf=- ztFtKAD`(A3pq($H9e0HyDrooJavF)<0oUES%GkL*_30J|U~V-fxpz?0-|7yg;Z>Ji z>VD%;^c_4NKVbZ!q=%ba2Df201EJvpkpm z4Wj?6>3g*;S4K!^?Ro-B(;kr*l4z1<7ISKpvYatnUL~uJ9wJk=|Cz+TxRO{7|BVl2T1!psP4e>bBNWg}G`}(nZR9; zxtnSh(`K(+qaMrZ@fSvN-bpSwztURTQ`!>l}i=KZpK>bY6SFY zS^l}2IM}o5qG{<~?HmKOymQ^x7L!C8Z^pg-y6p1%w_YKgRxR_cPQqkUlvNFBDR;W* z>=U43!xp)%XZ{E8ksGzOEZ)d1rz zD+XxGzmAch!Yl$PUO^IcGp6tNE?|phX381rEC}VZpwN{CWK7FzH!yS@<_MbgPI@|K36_i6vTB*9o&Hye>UMOuv-1ma$j_)bqUN{JS<@ixa zK(8J~%>@upE3An2-Fw2LzEFzGMerpcHSMi2f$3HR#wTp`(W_M{{rZP^ax z;YdT`ZkC@>4v!~xb7SHbx%1OTJLi?l8x$&B|*mAe4!@NG_)WU z4bU5vn(vwT?wh2xuFfN%@2*`Xzrj=+C@{-h#9>K7y3d-w#XSx=mzbz&fIg@cpgdu| z$CG?;>PynrqzfzqttM$ysjsEM5x26D2e_|5BOgG)92pH`cFS01727m4;+*t3dod$fG)wf1ebzQ1oX_5Xs{T5R02@plX>KB{rf&S;h@m*m<|C|!9yeOb^;Q!SmMZTVAaYV zq}#7pN;ZEXoxy&-d6QJt{OprdQhA!zRWicCfh-4PNV^304UTqZF%eJ~=s-1 zx`_OfW+^E^*?Zn6aivz@WQ~=C(lqZKpyuFcxfD@ASV`wBJO>>i%pDMi6iOR8BzK|M z%&CBZcke9JZZa_E&ld_)kSFe!1 zYi$Zv$`XJ~E`ZDqsRPW4i)O#WSh!7j@PSzySh65j?!oH3-P#WBC zN5_X6xX+v^7RJKEU{yoE9Pi%}U1?c;^q?f5%_okLc4l3G07jp@T!GPPzWrX znI>1NkAHP7Ie+b{B%uCyn>ZkVJXw#<1rXJ>knT5il8!N$=V@^jH>qH_r~h5FkxO#8 z+4!xmlb>$Xc!YDEe%=N<8>rwcWj)Nj4Cr1JaOa(a83V~DijtDr(AO}?epZ&5#inla zV2U60+B(1GzV)Tf`5<2aHMAb9j;HsOXu6pQP>yFn zEI8|HS?rlNeHYV}>+I#0#9~ng7}ReBEL~D5SdmW|ZSuCa*kDu%K)3>SI1SQX0g@~X z4LEYIgt)lTOxJ`2AJ-`+B(!6(=$R)A*a>U~pJGuG1ypwCYqIa`N&f_oJ?&XG9y{uO zqtTc6V-hmg(j|0Q@u-#QIDB|}f7ZDMK^(jN5d(|o?qR_USBLtvnpE$^$`)iC9_aNA)Z*Fg59F*@ezXJ_omj#53LE<6HPhLVSD(eGP}9gU7R5A~t32 z-HR*jH@K{{KUHywM6EK(H8ABVJ!?S~47htD(~0XQ!!|6oBYlljj~P81@-yv9K+0?I zC_;q@XpPy239%drI5`}SF4eAj=z$cfOHV;3*?y!T2nI)Y!W-18$Ht`6+JQsDm}He{ z{m{w+5?N{?Vb2wkwk3I_I|UQ5#zfSZK~Y(Mrc$|Zx6)ELV@GA64lp39vz&0mO$AY` z;TZM93CUnc74E{y2dPOxOOTqz@MJm6dT7kSX^iXzm4fBOKxAYX(?y|81$}$qFCW~Y z9x`wOWTl)Fs%^Ux3mzH?W;O)S92kBJ$IiY>O+Kd{bk`*8v(R_NpMq9FKXw&*K}hhu zFn{J=mUcy#E7mLfv@@0f>^6-jFnIe|#~)O|0qC45A42=!p{%d{l(n43F)1bnpC;}> zrN3(OV`e(Iy~a8g2vAnRl7`i6akTllmk{vEK6T6v{A>~G;%P8bUUO>KcIeK=p5_=>kIEw ztNF1LZcSZ7!&#;P7^oh0&vcmm)Z3t4c#+jG4y!Wqah*;0!4g#1BK(kvtB9X57$|s* zaJ*G`q6gci=THwDg3}cP*}1U~t-xxeHZ{bCbrjqVLG{x_M2uGbVO$=)!h1mb?Dw!V zlg9~h39bX0gs=y3^*k73GQc+G&-eh_#I^;CiDBo&b7LQV0h^i)0=sq%XH_czuGku& zxxMp9^~n2@RZmVVf#j#(g|t~8L)QFLw8Fgr#CU?I`Hi8D|i`g$YICuz^dr@i49X-dWd-ocL zg75J&`rYqvAKX_srZ;N?+rl=n?Et3k0Mt*=hFxu}X2Zj~LTG3Nn+xXPqFBAcB}(|L z8`}r>#bfYTY~xo1rRl(qiYCxZ6A!@<8{3vGMMlB*_*nwWLaJLCaQ_G5OZv@|SNYHY O0000)iA_N8~cC7d)FbD<;bXq8d1PT>F0tgsJO~_`C=lu8H-FvgU*?VuI?97>L?)@L% zcmD63|NQqZW4-+Vi(!rCer$DG9RC~bqijokZ`-G|)Pxor;oczTbPQ)piXP-f-|1TT zwy}Jy+X~!ef0%iT=CCS94gTH3-1Zjc61D5w$~?j@ zw3}$aJPsYk`|RRlEXf}jc8)&#$U2w5dxZ@2=&5d?{mkv?(qzJi*F~@S`wqNHf}ILdHO5IOvw_D^FM*f!s~J>`5hCIo;yf-}10smgM?x7q zZpWJ(ymC;DHs-CR(0Jls@MK*LtT?nECO6jVjSq3Y^1{^t5}E>A&hZ{cR>1QHf;6da zHFR}#!5?=tz{#tZVbO>0>JM^{fJE#_B0pD!Eoz>x6n+s1SdFsjS5*WgovRm{}3WGg=V8D2^i7_a0z9gByjNa|HB}+Lk-G^1xa*ai9!3qO}5~r#Ng&8 zr=_y|xsrTzmkHxHRENl7^Rc772Ol6|1*uN*a~b8OzYoz0w@7$h*u`~Y)VTa6G%e}4z~6u{{F`5V{ZI7*}^ zTXk?Bn7Ax7c%Hyh7OEfum`01UFSF#yqk{yJFd&>(LqWXqANy6F{T2iG5AUppSOnCQ znR=8?81S3`W+B?x9NA~sxN-L>%eetKz$Zos>^^r|MS$d_s%vzoOC@+E0V|nEz`CFb zF~=(lxmYY!H|Ql0hbi|!Yq!*bFx zgY~zza)M4>ZSCzU0=v()^lr0Kg0E13ciQ~SjYe7MPnJB6Syf7;OdF4fhOhJ#bHCrR zk!xN_pt@<__vd{&AD85{vB{Gri0YXXt1PDnv68I6>nW#mvEI4W?GUr@Z`&abCyi?8 z$rw2cg?PW9yZ6aD`Kc@~eJ^4itUe0JagaWHp>!^dDx0WqXS*3zNB<~}5mz5rR`T6zK8F?J5>R2fs&qj>+F+a9xI8xFRh+St+89s#_ zxo{rtUs~QX0cU`K56>!Cg)VGi!-kCtf3O!B-NEL+=-^uO_Ph!sU-!TXJPH~m@P5mG zp+9*zIsx>)UG)B}=N5B}VV@5L84btN+2Lrh@F>ZP8qsVLkoBG>iWZWU1(85dUUX;+Z##*$EVQu=>x8a2y@4$0g>mX%YJv_e& z+t#lQ*u(N>7cPAU2o(Cn%#+)}o}Fms*wUiC01-*t!{((px&FL@-^d|n?3!h;?c{Mt zs;hz54MwnaIEzrEO>8Ql%lO$&Ev9nVf6_t&9aGOAxxUz+KBN$OZ6aIGW9^9f7z*4?r8uGG;aK zW%UYDVB3wq;2>v&1zzaKIXXFbTBUj1^9dXS-*FASQk@hlkr-uq33XVO*DgNZ5d6I& zLRqCU;Bl&N2)Lh&vp2lU(IBB8Hf35P==C;xb-~<=50yi zRnLuF*ZQ%}}XN+zR07*qoM6N<$g3OUq?EnA( literal 0 HcmV?d00001 diff --git a/Android/EasyBudget/app/src/main/res/drawable-xhdpi/ic_launcher.png b/Android/EasyBudget/app/src/main/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..50139918e3c9bd4906a7c41a892af593c8ceb77f GIT binary patch literal 4873 zcmV+k6ZY(hP)}#;6VZ;frKO^0eVY9T9S09!@eln zzyJb*$^-F@3yjG42EnJuILdr5D8u4{tPxZ&+kN+(Th(1jx>MarcXdap?>p&qcXi!+ z|Nq|qoOA0|6(P5_Tbt}gi4QRtRA?_J`?hN0e(;-HTN^hHq8}Q7+SZK|;wPBu7O<`L zZ~$vfmVc{&8w`PDL0KeOSbPUDm&B2((s(rZA3lTcV4v0q5zDwWLL@!3IT!{*00Xrj zvu8+kaS1UOZ)C6Uu-8wCS$~ebUSakqyRSFvtTN2yeH zSZ6H--lPp@y{v*YutoyBC>KhA-WnMasX`)Zu&z*NTW#qOy&gh6ajVjgXN~$o0=x}v z_6id_gh<2}%txeJ+5v6y`q3a}s<2h+d$2}qmjExy4E1so<%we5p$*U$YqX^DUPo)y z`lp0=U2Ifc2LQggNk_w3Y?H0FdY#^z^`*^vePag%U+N=jtx=k89&4eGcliaeBG4;L z#hWeQn>u8#w{0fLXsFb`NDPU7-a&%sY$2JcDt=p#oQ4jf^=7fx0h(LLYrXV zv7W!g5XLHYSOCASgG-KsVJnOT%?HF#7~z?bAQq^`o_`3z|2avHLz2YWT&_J#($m9~ zB|(e;;I&HsmeSz+R+rG!*S1srfxT$Kiw!hx?PAOO#BJ-8^$YXo?j>0X{>ns0(ipf( zzsVD_(`msx`u69?X+uK;TGR?79kX+b<-PIXeg);IxQ-D)$#b)}NTMT^`+I+tu0-N+ z71jI}1T6O$0U`+v%qde+QebfJC=k89N&BQE&R0Cz@iGFaB{HheP@>Bo<(|k$bTE8+ z;jAa)d|yWXs2v+=?i;(%0vDFi-m4Z^-v4gn^B!=_x_ZRqxjE-ZUPiQQoia>7@kuBj zR@h6wxQNGLrwsmpNl|Ixs|+iZ1UDD;ev>vuGPmPa-Oqe;7ybLW(;l(;@4Z72$hPhBN1)LP-L)<=$Dh}*}(6Wsr2vSs5^aaUE?bd#$ z%zx}dDz)=@@7F0ZkN??)^ByB$?2b*K<%|SoMy>PpH|y&eUX-Y_s}Z|46CnQ3PA+QsH~~lk8wr(KXA`sr?5t%)S4qyKejLL@zqV?> z57YjoO7{K%e_H0T-v9Zf3p8d)g_Qu&oF&1m6vl=mU+82mFFckD;2fU#%6FtacaXGLKo<2oKyttMQ z+pyAQmbiW;{q;hFJON^jF;FvG4W^XD|q1~eNltbUOOX%sVS7ZrT z^Wpo{zg*)OSne`MNPz4ZChF$oyuvW`mCugFAu=~7iUI$nlFk=w$kw&=W?gNo;MdmI z(So;L^DOw%FtxQ@K@3St3ce=eNB2Z*lUwrzd=}4#l+7NewETc&yz$?%y&rOfq%G^b zg8&N&I(Zq&o|498C^8_GL(n ze$?4<><3qy{8#}CUoxNm>*BYv1ngtUl2?K+&L05@CL4aBw6KOu7(ECvwwaWP1Qh3w zk?6l-1l+xTIsNjx8d(AsA3Wflz)P)Z!o~@J{W2#^vLB9$`v2Sb%k+=$)$hdKTWYoJ4 z{PJctU(KDcHunN~IzzERg){v2p z-rF(~vJjAZHv{CF(gbMszojSvOWr@gvT3zz!7sN1J~vK)KJPkt>WK&K5#URvrVLb? z09ft(;-?=g2L8?3TAI1%<#zOb2YWqOgm=#1hY)L<1f-3PS6YH1mX^%8q9Fz!p7~Nd_>cCZf&j6k^>Z5fje?6VjCxwol2a2uGHoTw=O?|JtSA z3x0Vk;3EN=dIDi@K?Y1kL?G4%qH0P&A`XdQQd;n5B?;KgVsbeG&emL^5pA71Xg2}Q zNRJKh0eGXf9>32{-_9@&L9APxoaZMA0?FL$d6L`6G`f2d?wA7$aX&bBn)-N5ejJq^ z8{mWNRB9TDN!N(SPEW2zjBTEf;U9J;IcceqnXl|1sm?-660BYE3nwTc>18#A|L5mD@*YVF}3Ha!%vovODg=Yz9E(33i1 z@2}J~@Kcd9z9Ri%??r4A<*t^AjfGJV$zmSa|0K*`7_auCy2^}neM1(MLv-o#LJ%w6+BxqJ6GM8IK|gzaIz!j)!x z?;Y!vF!S4?|2DN>CoI<0@)Pj|nI}kaa6iN}(b0?b5)uMYhVrMHgi+wqMOv^9X!Xa3 zJ$5P52A0_Qm#f3ntE`9f!fW2cSVgcx!xoxE=ZXDply>i;`7l)iQ4D0pb| zD*Dy68jle0^|kNl{p*%GNq|h|YpeIqo^g@H^&W;8Cid*ymTTpGNL)9#wR<$lp1cSP za?vP&g}9!&8XlH6MlL9G5dqDo9fZuctca5FqhCPmIEHP!Yf3!&f(Q~G+@BQZA9qz= zpnLaik2&CXPo1PH=>;B+65xpZm{{ZkB;-vy$)d=65xZFDwBN=khzr3rL{e z?8HLN?YsN=K&HV%H?MK6ZCXW#Zdgf&t_NFT*LpQy6Y2=~a`J=n%yK1kyveU4Rva^z z+%?OGB-{ouY!r!#x}WHBPa)HA4Xg+5Oa(baw`CPH`uQp}xP@7lg=PHxaw82jm$Y#T zqK$JuXu3L-p4_RYNo3^1h*f;|U{~~haZ{0)7~aVfRk+#$L>T-02_WOs@tBlp&}6xU4>I?1IWzu zy;xav%F2+?iAm6|hDf>#^`*YvK!E4vTT8(}1VVw_1aK?lW z7zPPk_e0&H`?jSB!mFsL?(DTE@%K+4x#@?ja^xk?4geAWw6-iKw+DU&o?vglo}V%C z|5)1}Knyq*e(sbWTO^21xfip0$c#z*ERGqXBa;`900xnY9Po1BS6IO3Cs@*wL3ZxL zwp*R9;!jxe!8IGBxF8tRRvg+*#Z!MuSecP@*;9TUlCIq2u@c(X%Xf z!WeM4gRsrK?E#(uzbOe(!QF+CFhG?uY9Z0))nbLs!XpVy-a>-L2I%&By+-KuTE4J$ zPIj$oOwv*eKA^3<%^n56sT_rb{EBq$3?#(+KKe)o$TzNXNYLg|@?T^3u(rTit$x_HFcRi%;7dt>Ff540!?&~7J`f+Po*Y_4@~0igp2VNW z)xf7Pbd4UDBulSXH=^7C@cdy6l;li4p?Yk{93jT zWYW9*yLNd#%I2uyn96;~Q1#=#9R2L?;Q3wvSNv5O0+nM5R( z_3VgcByY+w^bvSRkz$dI20Zm?5+9rQBLQMAUTd@&Y+(`Y$*2#y;C4!fIrJ5Rl|qOVu{TzQjmED0V$tr z9T{c`DG<_{agMZo^cM4=z$o8n=byq#a>#_eJIm`Qj$%tq{4G^wyDL z%fxvgqY(2$7(B1Afv9sXkeu`*aBL_8%7QX6tb{s1T_P>O`*$R*Zx0cH7*9uZiDt32 zI4b%BFwE}vjqNp3HGJ?aRq}{MB<@Db8mkgPZX(7u=)S*@?|T_4;R zegnUSW5BWCm|S8y31z8%>A*WeNB~~c0Kh?(4J!>oLb{<|1E^Ai&)_@Q2lnM)cM*

=Xg%B@hzkKj+Nc&F->EcJH=(H@lzDF_>(*cg}au`JFR!=Mv(3_#VE8 z@8Ns+9=?ZaACzcFR+bj+wP;c<9k#?NH_#~H2Ak8Ai!%a-_Hk^*F_cX*s7je zm>NQIGwP8QnaxO^p)Dy`)P)pfbVq}K!|&j4@Emwot}*tdNhbJo$@fGIROxzq#@>LR;k0+;yA+alCaeykY-%)S7= zsN~{@<=}##Ey~Pb4moJc4xP0LQY>>BGq$nxUP?cJ#5vKC#Gt&j67ZvrmNHVNBmsi@pox#3ib7#}uP*2b{fH3WHe)tn8= z#}-k?2Teu#s5}~djALi5)#3G15XO|1F`5m;1(T?A%Lj=qY0VgoE--;T#_HI5z1}HY zSm28~PKrhanT2dbHQud@q$(>b%b%V6TPCHsPwO~RsY%l#xoK~c#KZvaEcGR!mz5kL zmR0ytmMcLGI^9JQdBB;)?791vtF_G#h@ufyqRx~Tsjn$TF z($@O@pVP9kGNZkD_#pni;ktZ!;M57Tzm47Us`AfC`r>9S_>x5TuBq&1V|goG!w&il zH40|014x1>-$2Qtxm%cTv=+WnTHU^7Au? zmrqZquSw{=0Eq=^%Tg#wnK@2bm6=3y)?8RYcvj5|z*gMZJ+BC6$M>KZ+?WF956WBYT6`s+QFy0q+oK38h$8uG?V z^wRZWGx4hiD`mE3-^Wy+@9I>RxS#pZ&;6;aJax@gYUhExCD9bpYL9$2{}B zHz`@VfRdav*QK&-R+Y_<%W!Wh| z1@+?OG7>x04T747IEX%PE4}B_di^R|TvDP+fZl};xFtL;gGfwsq$IqLy&;#SF)8{i zoj0I-LA-MN9Z-R(5gxFdk9JfH5EzJGXF>$NTr`)G_z91?E(%G}C*SZc2gso4>5BB- zAE*)@s2V4}wYhSDB#82y1QGZWqR@i)9j=K&M(vZ_OAV8y(13gC!2hWdpwm|_(S9#H zRW(3Hh|-;>g%-}C^ZPHV1n7rTCu!>^3v2_(Smrwc zBA5WrVfrdL*2xu_)b~k_Z=~eDkEMYAfmJeU1ZdB%Khs*!pRorZ=k$trU&t`~0*M}S z2l_nP84&RfrIxAx`D$6J(QorcdgjVyyPbElw3HrU5#S3Jy*y>PA8n@^d*PAHk*;+< zis@34HJ_51Q?tWeheX{1?Q@$Ij@>d#WfAXQM^l|v~U$pJILdP7a z1j~`#E8=1rO8RNz26Ep!BBpr=(~;cN6>3^(xaG$~vH<$%*Z``d)S#lhRjo|6rCM52-;sgX!3D9{KyUEP!76<}3O7Ly5Lb7DQ#Jhf))MB)vOC z$WLQZ`dXYcz$ASs-eCRm`D@o?0hGRDoAmv}Eg z_I0g!HI_d6G8y(@S7gDirlM4RElM6M^p7gGp$l2Hl)ioBds*r0->h;A&d>0EfU<4q zTJxr28CjgnbkNv1WFb1*-(LOW$5i|(4v;$Z4+!Y5PaKoAh<)w8J=AYmTJ-=Z+lH<+ zq%o-CW=+|MEZD4rAlF6XMP~s_czviQfLg6zNzY!rDr*sYBGXRQ0vuDrhGQj5JWvNs z--|j3n6OC)N&4rp{&m=!{~`2`DpJ^euRP15NvYicT`ayv2YPJ|a9R4e&^2b(ySz3+arcn$jt_0D6yAZq*Z>6mtWJq^>n;BUXvQ`^8PjM<#3n zh(9TSeZW&{63~s-70~Zb|0N5c!acjHH#lYxnM{31s}vI^Z$c()0;oS~pqh-vEouU& z_oip)@kGi|qpi8&v^B^!Vi5 zv@cLVt0aYudixc6`TBKP0R4Xc96faEq+9Iu@4uzNc^Mvn$3}2WEjOLd%qLI(mN5|` z*r=qG&%#X@^&}!9l!dXwY62*I*9WquFx+kL96I3KB_qy&XITZ7omxh6Svs3H<0uIZ zj^q=oL|d8u(czQ&_pi?s`JZa)0lj=s9hdPe+r3={@T5vsh8^qV87D~R&dmiI`l4*!&@Ruca1%fd!$V0<$}OCDO-Z;y2He@hc7P@f?Zn_IRtrEKHm*^H{`u+e z)3oX8WeS7G8aR?k0L{Ek?!UV?vQa5Oyj2}Q#t-Yt;JKj|fJVK&SrzGP=g}iHqHvLC z;ISp@){DgiWfPaERF#YPIS=K2x2OGexVPnxBbVJA8@;WD|qXHVM! zdhp&>%sFS&08rZvtLU%i&ng}uSaZ~TO|A<7auGbaXdIyVGtQE3-MUl_khBu9XU8Ta z&!CFMWcR$ZUe;wi*Go$1_8$+?=a?>Whpk~c&0miGaXCC`6~R*uP;%S}(x!PEWP?v< zQ~?knwb_e*P{UM4%(gdVMTEaDU8Hwye1hU`6z(#2z?1C&Jh>K@lg_6y5@U}tCK_-N zO)9-QLrbD?SB41o0sGVd(8@3N$nMEBSg};a%QIXI4~XX+1Mfu7jQNH!5h>WHbXlv0 zL`A`q!pV|(@2Fup$NNW*$O33D6W)3la8&~226({o(n<+zHyk%%2L=_+Mil_{=n=?% zhLMGHpH@0RwN_`#-id7ESNps?`q(I#D}o1>_~xaTlBF#5#*E#JOmH?dl>^ipZ_Zka zq$Ff0eMxJlXP=;dUc4X+ptz*f47-ble-fSe6ap7@z>xJ5D&DH-xm0y1F}K!ffGz}07?2H(w`r05CNq#f_k zl2ZGx13Sqou#QhJufFi)Q~?iK+AY4_UBY!Tp!dUkMPgKqssZA4eh_Kipc#9kUzJ*k z>53N8C%@V&D}C)h{s(R1j9GED3g;@}N$1O1VR8MO*khz&oi@ls54^Wnl~>}MU4Hfyy~D>2xKXP6wa9_iw3S)6COJEi_5at?)dwJZ;DKq2e38E9$Cr>kJ;$K$W$2%B9Rw@T zLP%{)V~dj&v%qZ0o}IGN*Kg;}(g!xJr_Gn~0Ft47 z@(t3aQe`;%KK$VV^m(I3wUGfL|DItw5{U}DE{Tfj%Ea_ZPd61|E4zcIPO9>Dmglcs zrEM%X^K=LxXQnS`9vWaXsnaZI+=)J~gA&G}+g|cCR=+;%4bzMyO@9tQ#Dh0pY4TJd zMiDgvbl~K1+HhsI3)7da;@NP!!F(qy9ve68f6>?V>eY25eVJEd5h$b@lK#C1vJ<)C z$@#JVuWXckJ10*Y^q@j5Z>a0k@?B>7lEm}e*b;JA_Yvsp@bFr4tFPPB80?u?H>e)_ z*@7wbEuM@*)8BuK7MGN$4j$M&Wc6nsQGIr*a{+QxJWJ6BX5J@7??4}OU)OXreOZX< zP!Q9N$wPgIk)2fmR-z9y z4kwKG0Da4SEG-^$3P6&W_9vkjg_@E+-3E~*$yePSg(B80qu>5@LbVlG<5k)60dh_C z#eJ9b_%+h4!$|aP2u2@&N5ykRQ7DMiuH6VpO_=;V9I5z(iPGfMZ$OL`!bliHGuACZBZ1{AudJ01VQ zJF0{SEdS#Vr>H5vY z->2W7Jnk7gahx7Jag5oYcH4Ky|D+>ceZdLoE7kGxJr|?TO(@$nQ%8TveArI-GRzg) zr{an%GN?ZG40?Belw>4b6L-FIX(aMzWqe@aA`cn+Y<7-;%u#{M>^J?=6xT){+w~7# z@1>a?Q!IpY3s+TMl|>;xywo);EQjQWWC*2-TWCm*>~gA#c89v|cYh zZQRJS|0~b(-@n1^^-Z*~BUiR&yF2B*3kOBhWz&es?V_w6x zB`Qm0P6x=-n(QO)R~ThTY%M|C}!x++R#A*>K=C@ux*L}pCfjY|aJzMI|% z&|8};uEv}Rj-&8oBvQ@z5uY-jbV1+LB#qEl9!y=OML zF37wobdjQM(mVe{H+;3%XbG_Vg8e>hbvDh~{U5Wx^?G3)UyNl6kt^<=BY%6kn^<2vVy?3+Pho>qhiu<^4}MxN3k17nfV~PE7khX8Y$D z%3lMHb{9Cd!m~J;YGTIxtd9)82WJepPa>3{d#chyfp|GA7i#VFEt?G>v#0)Ij6~kp zT!b$MIMP`+dvgjrV}I8-ZTbLCRfLANLZ5Ko=#>bM`RYY3&?2SE3?Z61B^%+%5t@fpz?!L}j|zCCiYrq1#81iaSWb2D%Yiuk?e%rT`;h4q_Velt zA-d(YQyrMn>;T8nsFMq&I)leBn`$PF{Enrqz5=PT?JWtOQyT+7l@076yJI3w!dz1QFP@jYZzzMZyC)kxt81Xtuoyi9pqLH^k1P-?uoT{oUEAYTh@*wn+&N`iv{qE6VSm^|u3TwVzS?X{)8 zLAnpAjo?^IS*%2df6rqnYuvDp^mW4eVb?!AxFe1!AKM@`Y93o7k)P;Az(igX22p#M zp8;K@9XhZ`ll1r%T$omv$*Kx3j7F%7((R8rIR-A2S)|s?n0!V1$bG9&OTye1{~|UK zV;bm<@K|d$LN_!VufYyrf-lIw6NF)1`!++#l+mAJG{Q@UKpS}>NH|wzYr0}u2ci~4 zo$Lu-nL7VkqVf`@b zu(4s*7RQQ@nIx906+G5-QB5&RT;C!Zb=9?NJ&cSSx>YkL_BvK;F!0zr5~(d;<#TAT z(OaE89VIXn#5^WaT_+=+<}R0HKsRyto+!aeF3S;z{>JjF;_KXhrF-r-f^M;r!$TL zj0KN6b;KA2RM)6uYc#4Q@_SPZ*+D_jB@OwIDke?Wn;ovvV6M!0CeL%+pmfF&4FaQO7=Q6eOyP`Z$RG{_U{x z5D`fq64FOI=)N4yl+j<3q*xx6pjs&uF!9J##+~LtVBi>}Y7&kO1aoE*g=2)miJvjYfJAs)NNss0OG3ctny?TUTa1be%hl(+(TBUNddn zcg!)xSiNCn$}k5Czx^gqIjW(|-UHvW3_rsZm;w=L0Gj0K#c+&pth!F^$HTFUehyDn zJmN%&bBz$^9$-?Q``~c`NIW8Ojns*->co#*H<*dm{iDky?a+ZwX(mVSWt!p?_LQKC z1E8=akSQ5=v}9bAmx-N5%KvTkcko(6`D+UWEc`Cl92gM%kYZ;|J;fr;r*KTVt{tYZ zW9%u8wF|+14m$^~aioYNfm6c=j`KmPAqF>GtaAZj*@As@+w*U*F)D$By6YP>cu3pt zt|aa7K^w@#k-IfBCLJYnrd>qGLdt`#H-H6cMOva-4w!Yr&DKTv4X=eoau8jZmLYSe zUnKAu?6crAwfEjRhaJPiBGPojXyb2;Htq4)`8m`T=j4kxZUV_)wCzJhWzs;*1+_H7 zR34d(Kry>Z*D`9Dwr{UQ?IZUWYDYi%A{jSq8<{$0ABmfAh{R9+l|_tWBx%}dGAH&d zOF8G!=1xD${{J*diaACSCjUZaOgx0I89!_ryoYx10|oG&@ZNg8zPI?SZsN10NFzbh zQXFdoaqP9kx%m6QaW6NjydZx`H(5f}T||hU;Cy{p z*Z>wa2I`tM8mw#H^f5H{Z}=VljRVpP(xu7k%VFU{m`54->;Sw%rMWmJQ)r|#!8i7OI00000NkvXXu0mjf(r=gt literal 0 HcmV?d00001 diff --git a/Android/EasyBudget/app/src/main/res/drawable-xxxhdpi/ic_launcher.png b/Android/EasyBudget/app/src/main/res/drawable-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..597132d09058e1754db9912e443bce11eb5dd54d GIT binary patch literal 10699 zcmV;+DKyrJP)PNM^Co%|e-q=iq*;jw%s=1EoaOAY;O=sE**)i+Jl}(XEvLNiH}6+w1|jXx4(-qm z?a&VG&<^d;4(-qm?a&VG(2hnO7zt?IIvsi@Nl$IvIvAreF*hjuifN53}60jwrN1S($_O@79qvNb(XdEo$5R(KOCFBpzC6^*0@ z|E9l#zmeC#YuS4u=x5kV4viGr;S#S49=E<@^QvG1)Q)I#(Fg+I6tt=E4g%(f$iH7h zB-r zkA8Md1Kr~=7D5=ULGsVs?BoA@|sW6dja=EIs$0&NFmuA zskUdq>^>WTt5QLKEizaK)cfc?lEw;J_Q=bn&h~X-sH|uSdAMKH@%Ai$tx*y1ZONo6 z8>0f;t4vguAFfFwZwqe41Byfd{IbGttqM-e^Qjl~4UoZVRDgR2_fT4J(}HCW)%oH> zfuCgsv(To(Pc+~+h6s{MHQdWhg)=Sgu@)bApx1|fnzF*Ttbkvo0pCd?u!5d+M0ktd zV{J6>2(4cW{PMMJS*5n{iUj;uSzCby=EfTx5ybm?h23j%&uu*m?QpZ#FE!3359F8y z@HLO`N@*nC=VSEVYomdiwZ3?4DU3noMgOqM;a6)wbwdi(B5OpR0Xz#^&qzC5BzejT zlF7|KsmUFWkwquzGtnfGizJWeRUj{2UUCMq`F1i5d%jD7}{#Zq>ZL z7mfz-oJ@v0Q4${kG!i%p{^gP6hQ4H#<A67-EZDpvU zz4;obyf9tLomOf7r>H0=UL%1$#VwPeAGvWqYI$!C$FK9)(zWK_z{rC5AJoVC5(+O={`+bv(^Ek=&$<8u%4Xf>OgpRFpf_oa9g~ z32HsRfJ}u`_<~Scrbi7Kq7p*|TC;pQefFvy37F;=$W57`fnS9rD9*1yg*mrV45%(J z7QtUqFx;ZRrGZ~vB-ogD5#=u(r7jYf!Jn5vZo(-wfIsr?V!Zw1_v_fS7oRl0rt5=u z;X4n$XYu!K#BO=&A?1<)ey(428m(S9NF5}Q=K0GD|ErdOH+IJ+TwPsV$F?6jV1CWW zr+4Gal~*kOezmF!uiE>%vIAoikYMGHXleTO%B~ZLsZP3Fc)`bN%Jy{72ykr@tX=*Y z%1(UAavCsU{du)%evJUvA_2idi2N=k|} zSBt$)_cQ$_5Jumx_Ci;{rZxD+UH8WY2Dy9wdE;c3T}yK>t&% z_t#ngG;>r688IqY_6u4vt0TiyqL1fGA9m=F$@sXEq)!ad@k1WlsXFVudwbK?2S5s~ z%6!&bAMj`?BxZd~h8dF0p=ebgdEq_6zPr;o^ zTgAa=QxQIU@uKR$UsY9w7w!26BLLXOCL#gU2jKfxE#65nmgH$^QFFn|I8ZXJQ88H` z(EpH=R~rHTBob)kqYu;)z)S+8`?EsuorTLTq0ITiDb~_GB`YN6`&r<-M~PXVanC-Y zO8Il->>1qSzRi{d0Naq)1SAjx;*7Tl)_lzM0T0psI#g`9U1@=@XnFhIP$dCABymsF zraS5qplL|JFjbU;QC3+@_A zKqNt_(Y>Pp1mmlge1e7!4dq#(CTyE#I!uR34D*%h{hMzq#(RG}s!9T^-}hGi;5U2r4svrqoW)PCK{Y!)H&ak^s}6dGy)@FpmTV z_hp1&6v)f?28|mQ!4-(U3B7-wV!c1u4S&B6pR1@)9r&+Yy^4F<3V<6j_Y}<7C_rO@ zxw#aBZqEpb-aj!hfSk~WO78cW{^FCW^!~^H_zkz(yw))S(1KaM>uUw&JQi4)@fnJb z4|V@2z$$S%v_9{4C3}Ccwf}xcl?2%T!x0=V&d_p(1n$TP6=Y+yXl^RSqT8y&wRwLZ zWHP*}WbZE!06hPnuhdroCicL`?_)mzvNHs5N5Kp*2eC))!o38Ge(tLah}wTm?oji_ z)C%36G-%6O{MrA0Q5^v)iT=-f>t!rB3x4;Ipqff8!_rEWHhq-)GD1vi=}EwIuhQrE zLI&vn{`g&W1i0{51)lo!!;J&KnFKB@m;v@+(#+DdM<^y;9tVipe??|HastP>_E#(c zZhLw+{_WC5nX)RC_=^)i;X}uddA#jC@*mt~yGb4bxUygd*n{9kGx!yVEqeWhIuq5MwWGhD!M*M+$I+YCdc3vSbO-j?kS8AjTvsco zO^K$@%cIzISuo5}pJ2XZNP9!+Qyul>`pQ1oPXj_Z2=LaC!#LDbC@-kI&0IkOb1=MM zKfz`o%LuumJwo*UhGqQ->&_`T_yzOdyavDg{kO8|>g^xCi+v#|R5JlwQZOrItysTu8Cq) z_F#A&V)AEjbRc2dentSJqE~o}zPF5KL*yX9xr)np?Bfq=z;{)_41!@8U2_(EgyJH@ zm<&QX7s#K?8`L0wz;1rxZ?egs(-+Rmn^jSg$x|y}HG$Qc7tqWJx5zJpSn)ArV8gXEAwqWoDtzatrDeo_MgLhoFUUpx9A z*$7bn@4Xtp?InS;1v9`Tymv*$Ni=ChPx)k!D1VA_r%8%miq`+I3dE-;Psjp(SWP1B z#iulIJ5nofZX{sqi5jypnm27B&jvNJHwe?7e;+D;Hm)vH%ObZh3Fd$2&&fi7KQ3Rw z!ydXv1GYN~W`IptMJ{9R{S>o+-9~kYij8jw1)!4rchr#n1e^5SW3shCe1GON?s{*z z25M(WP~rdyOkz5e#sMD?%!W|xCN`!ug6V%BlsP9(E&3k}Hl>hD|Nra9qd3Z$nN?~p zlx=XLU`BVCA@XT?`bji?cyE>sYD^r!-t0?v^JW{{VWp=(!5;nUQ`rdc=;w#D76@m6 zFE#rMW;GO}hNabL*2H9r-K6eL#{mK-fFD}7VwGC-KcqlEI&oY!0u;d(M;f@D2fvgA zCVONM!6?)N<|mg>>@o}s&gK6T=@N7Ss5tK-wdns=+e`8BKjoVQGwI2PG;lWt_!cC% zM!`&s1EA)zc+RsFyMSS*>H+LcJ}ACLC<#X1QwsqGJ+cG;cIlEV;IF8x#PP5XmUYCx0jOyCawRW(CADUEZIEp%B$x!V{LPmd zzqE*|!Jl_++h|oaW|<(?89HzJ28v;)2rwqjpLW%)TeVdU{r_NX?ls}F7ca=x0x|x{ zT^jJ$y9oH!CX&D;X^EJhyo+L(Vc9_f^g|E>1fvbBpHfQz-2dTwWz+w#oJ!6cFX6FI zK7_}4hz;F+FZSJ-uPpFylo+&5l=9YS`0k8(uTl&%EIUAeB-;PuOZzE`^WUZfFe!fo znDfdrieB*QweP;gA>bjE27C(={KB5SJyQ&0T%p%hTPDHV%dQ}SR_CInmwr}Dl?u| z0|EN%z6byK`)`URz{zue;tqG0cpvyTItYG|0L65flwZ-ULk6>+Aez>|<~)JYuR%9SbH3jWpgTQU7vO}j^P7o9^Rh9y!gJ4JxB z&Fj@?iU`!2&NG2cd;?ZX_c>rP2ui zOQG*Orrf=Np)Di%*~ioYeo6om13*!1WyV!90*s?rp3pDIUIOT936Ojv0sj@H69BeC z`uW@+iX{Ln%{cJEyS)(n4gw!!512@R049Ng(fp)gWCXaZbOLmKU@JcV zmvZ+4{_20f;7;30n}`7J0Dr!vnMZ*13N(87Sc+vPf$SszdvP$Dmo%6FMDaoJxMv^1 zmn$n3O90rMBV5jjIi3uDDFGk|zU8KotRKjc5kMqB^2GiGAipb_0I(J6s(r8HN|~DA zJkfR>Jb?9bZF%G|;Lo>hCG;nkK#TrFw+y+(aRMa9G6^(gd~akj{!htu0Kd&^@Xk*^ zQgjM5`}G&x?EPf~f3bt$rvxytEKtsZvuIeqVN3!=2RcrG(Zl0Wso|KC2@rAjM*Pno zzE^Y#^yY`}^DOT#H~34e3?3hfM^WdPt`y6$)T!G^0C6%5LQ&+) z4=R}eeRtm@TMGoF8xtSiq2gIxGUfU0@%$wXnXwwJ&x5T~zakjEj$*kx@^h2`5Hz$P z7xuQ22{84ACuEZv-<>&)ZEvRfq3$p&t)UPQ(t|mf!~$7zaijD2~bK1P`C`ESyOgX3^Ob{L;yV+ z7zcAHBhiYCRZ3kJCH$@p_`?(bCmR6_Z@;P$peeyG62N4vL53`hrcEfJ81C0GobPkt z00FvphhZZEWv0yLBY@%-#_ICGR(#~Qv$Ac4H0i0`8u(?AK2Czalv)j4F>cl38Z>dl zQi|dD_)y0Q(5n?>ftsPD2{)4f_?!}#MjZa+E__C&b%4%SRN!7awrB)sTJVEa)79c~ z7Mw>n4w*zT+&wD9Q3B{tG%Xy2qyD|($(R19#7xlq*Po}kdpQVj`1Jp9^qm_t@XMX` zaSZ${0EYKv&O3p+ckV+m+_tUQ24VNwKspqM{P~(HZOFy$XCr{JmPa?ezn?A_A_oE9 zJaSlBD?xjt_rDhSLH`@7(E61a&7b)(!ESqs;aFsM5v-N~;<`WxwL(QXyOg*;z$*l_ z@~pYT7Q*#Y%5LyY68JU}{89oGBrpkN;LAO6pG!r$S_B00G0$`(Q_mkn9z%S!4FCb zgoi!0Q@%x~DlW@E<4UdG-&ydN7-3;tF#!G<^|*cr#jb-zFw0bEGzz3?BxopFyYfvX z1;XiXyeJz1e)#Py4pPcaFkb2Xjf?;$g3YxnFiJ~?9-tVC-L|x+P45f=baY8Mw$WG{ zv}#G2QsMy6|92fcAR7Vp{cr@+<}1znH-Yyzax*afZ^*`I`ozCc%rflyJDLKu%mlS> zA3>w()TCJ?2Dqw(I3VoK_4wr@@-2jW=Yjp2_qQkS?~wO*1pG#(|6y-{6&Y1%RALsz zY;<&_GZ`SuOb`nqqR$oWzHu6Vw`wPD~^x zb;jFeQ0_ag%BKH6`sx30%gt*w`6HYBagYEeE`P{tg{AX)NT9flnZ%>Er`uvb=1bF_NFR{1_*4s<*QJE&efX#mHag6}Z%Adwc zAG7?)C-NutHzU*A{))YbzcX=^m>xYq$0vR`JK5_?}ZXz-)=%a#Fx z6lkCOw`%goZUV^c{Y!cFrzi&tbCdRS`O^|bpzy}z|Lc=ML8xWRR7cb%wkM6`h2?;#iy4|-K``6d5;Wqs*v-dag=C>8;7>yaehGH`+ zsx@EI$43tDZxA(5fZ>VzGedSB4=2q(<+5`aEos zx6K0y&`|cLA?;7F2C2`Kq(cOQ*HbLAhRr}%$e+5sK@ey(Zw|{&cSOk(7tfKW8CEBX7>57>taJwhDe-LIBHPI25&@fd?=!Z}A88 zq&;{P2;6c9e&*XRRY`z1=q7oEO+)}0ynm@p@BcZ$UN?%t5WWPu3%!5cU>H{1ii~VW zt8ddLWTLezYCO43y7fJ!_`pxcR7rrHhYr|Ifyzn%cX)qe4b%P$YS3*rU{?me^;d>r)_r7PG)+T|88HvtvHS`LoX5oiBc6T;pHuK=*bUk6zweNj0CG3K z*);<2p8u@WE9k}niz((J1KaUcO9I^G{p+Sgfq?-imc{~Esn5|H0!?xrHVQ0w{dw71 zAiT*gUAcl|?<#hZ0C|lhfHTc+#`+16pM`~)lis9Q1I#h+FHL>AjR0aWEFA?pp}`5G zk#Xs1bDxF>5n%hF_f_frzdLiP;cc6mTK?EY0N(SHKId1|`}(OAYnI;M$DQD}PKmO; z#KOZnlArM?b^2X&aUwcl##rMiDbTlnIHF1dy!zcY*uTC-du7r8E|)(HN6QysG;!qL zD8|CWJ5kIr?D@Id``d~I!ck!0b(ERGzP(96{29F&9?~KadgnTPM5eufz0I~B*oXB_ zWq@RqKP67epAz2kK>PiteT#y8`%#PqQpFM86x!d`C?M`B+}u10jJ+il8M7yrh!@9 z`*);Rv&{T72?6TX1zq~09YG50Q?)=xagHAeaI*%ES~ol8gj!Dilwly&U{X~ z*z@BN-oI|AC`(PXL81O#pjxL(nQ;%@w3rc~MvY(&yK6mOvG=t)HvFkw=GSz%e>2W` z=T(cpFDL(h!>(;jKLOqqtG?AQe`tn`~xiwjVlRe$B|Ich}jm1`2n1|9nLu;Lo?u`-AjZor#6yDbVWN zn_?&+Al8!tUwZ=LFuFW-m_L(3UAv4xxeI@y4?$r&P1<7}Jb?hN0Kc3|9*W>E%EdzJ zj9*a4n6XUyPz<%=nDX~D>0>J(mP#R*r5{hF(D+*xqr4@T>5WlYJkX>B*x;gk|FyvH z2mwUhzdjEO>2oV|iNjVh=|i#8)=K*5kOv=ir4XI1;|H^Ldgx|ME~h*A!9|y;hXhSR z09ki?S>4F3j;^t{FU|Kuqgxu*jT^BYu);AD?G4hWAt}VNLeRM6N2jj!M5#$nAY*pz znvNO)*I(N zf}%wPiwFE^JmA|ZauCXx`ziEzs39nB6an_W#=zp#qE>c*-szp)?E(Kay?+(T%f!O$ zsbBjzAOiBV3$RXK`lAy7muLt<9 z0zb$e>iLsS>-+bfD*=D3BPmglBsI{4l3?UeZak=V#Bp1IL0`?DAC__#4U$wV^kFd4XQ~K3yjZ z8FP-%Isv4|G!i%v7uW-O8N9wD;4kKR{u1&mS1rcEoZElUccYrW7kTErIQZKrA>-4y z^e8NE$8Jb3pDrCop!vz4@``bdnkvSPBSF67fOpjEI|_bqV(<)?r(Q`vG{5S>Umpp?z&IB9`E?_| zWhA)urYs?S?r|CyKv3m{+p{)VT;L4w9hE#K61@+>?!0L~>xK@@rQr92<~F}>Jk==% zyc+l$2#m!PX$SgwpSajj!n~yStR#^(8eFqzX0RXhjq&>AnFveMurPhX z2l|dNW8s;RXV-^!0mn#Eonp2}1AhY~5Q}AOg$Wio^&&U5uWss?9YSX6CE86&_lea; z1H0mahCuI())%up@C=~1wP@BQ-MA5d<9c5Vc02QD*or?>RirxQDwc)wz7SXgG7bg% z^%O~DTnog5*ER;Vc@6F zDvm$9C`pA^1HS`p69K#_3f6#R+cEk1b%&)FL0kF;`VP=d8~=!qm2#OE&VUHoXkbqt zQG*`80l8z=`s8yA)azI1Cyjc7Jc~h8@58c~@QhfR^E&?QLalmxH1In>0)O5X8)*)X zVU++L7tqmQ=zxVn+KkUoE{P3bQC_+dokoQENx&tM!+CuQ{A#$zG`Bx<@)x=ReHX(s zFzbD`3P`LZ(-Xp6Ng_YL9_G=&*SEiJ^62eC`s`D*cm`>|@`5UD zM7Ty=U~e@Sf?O!95y?rM%_-g3Tkj+He1OF3_mD`QR{U8>Gd&vk8<{2*pyOYCHc z*Y8cA0ecprLj$j}P|@bpGO$N@4nhcl87Nq{F?XUZ31+de52HZ_l4a1b;S~-gko}5>utp z(SXDTe!UPQ0VRSiad56MXWBh;4V4Q-m_aZ{>PIvR+vK_~JEy3*Jnn34cSg?^L>GpGDpSdvnK z@|LjFDc!+FB9BZe13kT7e*NWzH8gI5DsINy3gYqKNwmJy>uV{_ zCL9ewcQ)tQhZru4xnh0IH zOwmmo{eX}<{X1dVe7gG}FQ`c+QjG*O$^boFTX`XChk$vx*$b+Kl%(%<6K;D@-=)(O z3*fUhhVHzY8_Vx$lqADro!->I-^8PVwJd5Wi4Qmt2nkrQOo`wV8aPxpe8_5H`ouSc zMYB%|P)mTI8RP&ZgXk%}gb=p8d^t51|G;!pa*gy_lP0{OOB|FB_rMH%@b*mVh`<+h zeoGs2NAvm~nkn+*X}C~Hd=PCO5fFk`9M2ds{P+v2@OHbvAVdUtEUlaUKn+arO#uyp<z~@_s3_dNI zB|^zxH~!Xph2%-^P*5#SIZxZNS7%~6Q3v7&y5e;qBL-wu%DMa~vog~)%=-1^W~s+%(QNnzHMcZJj$ z-wR9T{VFU?yG%P&ffx`=069QH0YM<}EHQ{XpVu7?OuQ&1wl0Hwf%gK)VdR0&B%=@s z+Aq`ZnxFhVd^el}_3#Nj)8O2oEkr7B;2NmJVW96O@%Ez3Y0d9NIKMX<@D)x3F<2JU zr_xcOodp>{6N@q_+#+V^EAg!TeIi0{q_SyXLY8jCO&fLNN9@qwKK3~}V$7X(kPze> z>YW$OIW8=odqT)eIYngESs^PGNb@WGH~enNyc00d$Krth6O>23YAanHhM zlFu&I4eXxl1w$W0QkCrHk#51gw+-gLDBeLE{}FQzla;8p{^3W%VuXHRFZg7r>&cf=<&t3HNZTPiC*5mE<8t~PZ zHIj}B{x--E#U;XZTqjEj!AA%pnPA>4>x}G!!h`zzwg~Uf$U#O8OM*CNLB8xW;IqhQ z!e`U(FweE6I$refQoy(8=iHiKgEi>=t-QUK)zL-;^Qa&tLok;J&A3h$3DK5Ii5Q+M z@5CiW7Y@ejIWW7iQG*hsHxgS4f?2?AVdC$_*D-+ipr64$>w5m#UHEr(;@=&^&!w#d z@Xh%-hw*E$2EFF(wZm>Q_(@_0kr3ht(Spm0)?8v-$HCa112dL`w4;P1qII@1>EAl> z_jKT&5yL;T9siCt{5xCna}Yhe2zXJ``)Q!pj>eL~79qqDB8bb1P!7OmJg{ueff>a? z+KK~KB#LNlxk-N~zOEI2ZxsKm=KQmp@$U-d-yJ0J@U{T2fnGagA%is`Y>^^>gE3Gt zVuVOY5+)fzY?=7?P|5p(B_I#v-(d^vwg9hzUOU`I3TtABLZ7)9Yk-&W_ - - - - - - - - - - - - \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/res/drawable/expense_date_background_drawable.xml b/Android/EasyBudget/app/src/main/res/drawable/expense_date_background_drawable.xml deleted file mode 100644 index deb63b1d..00000000 --- a/Android/EasyBudget/app/src/main/res/drawable/expense_date_background_drawable.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/res/drawable/expense_date_focused.xml b/Android/EasyBudget/app/src/main/res/drawable/expense_date_focused.xml deleted file mode 100644 index 922d06c8..00000000 --- a/Android/EasyBudget/app/src/main/res/drawable/expense_date_focused.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/res/drawable/expense_date_normal.xml b/Android/EasyBudget/app/src/main/res/drawable/expense_date_normal.xml deleted file mode 100644 index 3429f191..00000000 --- a/Android/EasyBudget/app/src/main/res/drawable/expense_date_normal.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/res/drawable/expense_interval_spinner_background.xml b/Android/EasyBudget/app/src/main/res/drawable/expense_interval_spinner_background.xml deleted file mode 100644 index 3828e39b..00000000 --- a/Android/EasyBudget/app/src/main/res/drawable/expense_interval_spinner_background.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/res/drawable/expense_interval_spinner_focused.xml b/Android/EasyBudget/app/src/main/res/drawable/expense_interval_spinner_focused.xml deleted file mode 100644 index 4e9bbb19..00000000 --- a/Android/EasyBudget/app/src/main/res/drawable/expense_interval_spinner_focused.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/res/drawable/expense_interval_spinner_normal.xml b/Android/EasyBudget/app/src/main/res/drawable/expense_interval_spinner_normal.xml deleted file mode 100644 index bcf14489..00000000 --- a/Android/EasyBudget/app/src/main/res/drawable/expense_interval_spinner_normal.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/res/drawable/fab_action_label_background.xml b/Android/EasyBudget/app/src/main/res/drawable/fab_action_label_background.xml deleted file mode 100644 index fe376aeb..00000000 --- a/Android/EasyBudget/app/src/main/res/drawable/fab_action_label_background.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/res/drawable/ic_baseline_arrow_drop_up_24.xml b/Android/EasyBudget/app/src/main/res/drawable/ic_baseline_arrow_drop_up_24.xml new file mode 100644 index 00000000..6fb1fd6c --- /dev/null +++ b/Android/EasyBudget/app/src/main/res/drawable/ic_baseline_arrow_drop_up_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Android/EasyBudget/app/src/main/res/drawable/triangle_drawable.xml b/Android/EasyBudget/app/src/main/res/drawable/triangle_drawable.xml deleted file mode 100644 index f61e94f1..00000000 --- a/Android/EasyBudget/app/src/main/res/drawable/triangle_drawable.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/Android/EasyBudget/app/src/main/res/layout/activity_backup_settings.xml b/Android/EasyBudget/app/src/main/res/layout/activity_backup_settings.xml deleted file mode 100644 index 02f4693c..00000000 --- a/Android/EasyBudget/app/src/main/res/layout/activity_backup_settings.xml +++ /dev/null @@ -1,319 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -