Skip to content

Commit

Permalink
Merge branch 'release/3.3.0'
Browse files Browse the repository at this point in the history
benoitletondor committed Jul 17, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
2 parents 9014fa3 + 0ee7e0f commit 5fdd028
Showing 194 changed files with 11,466 additions and 11,149 deletions.
34 changes: 19 additions & 15 deletions Android/EasyBudget/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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 = 147
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,28 +116,24 @@ 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")

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-beta05")
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")
77 changes: 4 additions & 73 deletions Android/EasyBudget/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -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">

<!-- Disable advertising ID and SSAID for GA & FCM -->
<meta-data
@@ -67,10 +68,11 @@
</provider>

<activity
android:name=".view.main.MainActivity"
android:name=".MainActivity"
android:configChanges="locale|keyboardHidden|orientation|screenSize"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
tools:ignore="DiscouragedApi,LockedOrientationActivity"
android:exported="true">
<intent-filter>
@@ -91,77 +93,6 @@
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name=".view.report.base.MonthlyReportBaseActivity"
android:configChanges="locale|keyboardHidden|orientation|screenSize"
android:label="@string/title_activity_monthly_report"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity"
android:exported="false"/>
<activity
android:name=".view.expenseedit.ExpenseEditActivity"
android:configChanges="locale|keyboardHidden|orientation|screenSize"
android:label="@string/title_activity_add_expense"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity"
android:exported="false"/>
<activity
android:name=".view.recurringexpenseadd.RecurringExpenseEditActivity"
android:configChanges="locale|keyboardHidden|orientation|screenSize"
android:label="@string/title_activity_recurring_expense_add"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity"
android:exported="false"/>
<activity
android:name=".view.settings.SettingsActivity"
android:configChanges="locale|keyboardHidden|orientation|screenSize"
android:label="@string/title_activity_settings"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity"/>
<activity
android:name=".view.welcome.WelcomeActivity"
android:configChanges="locale|keyboardHidden|orientation|screenSize"
android:label="@string/title_activity_welcome"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity"
android:exported="false"/>
<activity
android:name=".view.premium.PremiumActivity"
android:configChanges="locale|keyboardHidden|orientation|screenSize"
android:label="@string/title_activity_premium"
android:screenOrientation="portrait"
android:exported="false"
tools:ignore="DiscouragedApi,LockedOrientationActivity"/>
<activity
android:name=".view.main.login.LoginActivity"
android:configChanges="locale|keyboardHidden|orientation|screenSize"
android:label="@string/title_activity_login"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity"/>
<activity
android:name=".view.main.createaccount.CreateAccountActivity"
android:configChanges="locale|keyboardHidden|orientation|screenSize"
android:label="@string/title_activity_create_account"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity"/>
<activity
android:name=".view.main.manageaccount.ManageAccountActivity"
android:configChanges="locale|keyboardHidden|orientation|screenSize"
android:label="@string/title_activity_manage_account"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity"/>
<activity
android:name=".view.settings.backup.BackupSettingsActivity"
android:label="@string/backup_settings_activity_title"
android:screenOrientation="portrait"
android:exported="false"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name=".view.report.export.ExportReportActivity"
android:label="@string/title_activity_monthly_report_export"
android:screenOrientation="portrait"
android:exported="false"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />

<!-- Provider used for exporting CSVs to other apps -->
<provider
Original file line number Diff line number Diff line change
@@ -36,28 +36,19 @@ import com.batch.android.PushNotificationType
import com.benoitletondor.easybudgetapp.db.DB
import com.benoitletondor.easybudgetapp.helper.*
import com.benoitletondor.easybudgetapp.iab.Iab
import com.benoitletondor.easybudgetapp.iab.PremiumCheckStatus
import com.benoitletondor.easybudgetapp.notif.*
import com.benoitletondor.easybudgetapp.parameters.*
import com.benoitletondor.easybudgetapp.push.PushService.Companion.DAILY_REMINDER_KEY
import com.benoitletondor.easybudgetapp.push.PushService.Companion.MONTHLY_REMINDER_KEY
import com.benoitletondor.easybudgetapp.view.main.MainActivity
import com.benoitletondor.easybudgetapp.view.RatingPopup
import com.benoitletondor.easybudgetapp.view.settings.SettingsActivity
import com.benoitletondor.easybudgetapp.view.getRatingPopupUserStep
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.firebase.crashlytics.FirebaseCrashlytics
import dagger.hilt.android.HiltAndroidApp
import io.realm.kotlin.log.LogCategory
import io.realm.kotlin.log.LogLevel
import io.realm.kotlin.log.RealmLog
import io.realm.kotlin.log.RealmLogger
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.util.*
import javax.inject.Inject

@@ -165,7 +156,7 @@ class EasyBudget : Application(), Configuration.Provider {

override fun onActivityStarted(activity: Activity) {
if (activityCounter == 0) {
onAppForeground(activity)
onAppForeground()
}

activityCounter++
@@ -189,129 +180,6 @@ class EasyBudget : Application(), Configuration.Provider {
})
}

/**
* 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(activity: Activity) {
try {
if (activity !is MainActivity) {
Logger.debug("Not showing rating popup cause app is not opened by the MainActivity")
return
}

val dailyOpens = parameters.getNumberOfDailyOpen()
if (dailyOpens > 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
*/
Original file line number Diff line number Diff line change
@@ -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<Unit>()
private val openAddExpenseScreenLiveFlow = MutableLiveFlow<Unit>()
private val openAddRecurringExpenseScreenLiveFlow = MutableLiveFlow<Unit>()
private val openMonthlyReportScreenLiveFlow = MutableLiveFlow<Unit>()

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"
}
}
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -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<AuthState>

fun startAuthentication(activity: Activity)
fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?)
fun startAuthentication(launcher: ManagedActivityResultLauncher<Intent, ActivityResult>)
fun handleActivityResult(resultCode: Int, data: Intent?)
fun logout()
suspend fun refreshUserTokens()
}
Original file line number Diff line number Diff line change
@@ -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<Intent, ActivityResult>) {
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() {

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 }
}
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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),
)
}
)
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -30,6 +30,8 @@ interface DB {

suspend fun triggerForceWriteToDisk()

suspend fun forceCacheWipe()

suspend fun persistExpense(expense: Expense): Expense

suspend fun getDataForMonth(yearMonth: YearMonth): DataForMonth
Original file line number Diff line number Diff line change
@@ -62,6 +62,10 @@ open class CachedDBImpl(
wrappedDB.triggerForceWriteToDisk()
}

override suspend fun forceCacheWipe() {
wipeCache()
}

override suspend fun persistExpense(expense: Expense): Expense
= wrappedDB.persistExpense(expense)

Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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))

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<Currency>

fun Parameters.watchUserCurrency(): StateFlow<Currency> {
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)
}
Original file line number Diff line number Diff line change
@@ -35,6 +35,48 @@ fun <T> CoroutineScope.launchCollect(
}
}

@Suppress("UNCHECKED_CAST")
fun <T1, T2, T3, T4, T5, T6, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R,
): Flow<R> = 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 <T1, T2, T3, T4, T5, T6, T7, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
flow7: Flow<T7>,
transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R,
): Flow<R> = 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 <T1, T2, T3, T4, T5, T6, T7, T8, R> combine(
flow: Flow<T1>,
Original file line number Diff line number Diff line change
@@ -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) {
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -16,142 +16,90 @@

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) {}

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).
*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* 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.serialization

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.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,
expense.title.serializeForNavigation(),
expense.amount,
expense.date.toEpochDay(),
expense.checked,
expense.associatedRecurringExpense?.let { SerializedAssociatedRecurringExpense(it) },
)

fun toExpense(): Expense = Expense(
id,
title.deserializeForNavigation(),
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,
recurringExpense.title.serializeForNavigation(),
recurringExpense.amount,
recurringExpense.recurringDate.toEpochDay(),
recurringExpense.modified,
recurringExpense.type,
)

fun toRecurringExpense() = RecurringExpense(
id,
title.deserializeForNavigation(),
amount,
LocalDate.ofEpochDay(recurringDate),
modified,
type,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.benoitletondor.easybudgetapp.helper.serialization

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,
)
}
Original file line number Diff line number Diff line change
@@ -13,24 +13,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.benoitletondor.easybudgetapp.helper.serialization

package com.benoitletondor.easybudgetapp.view.main.loading
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import java.time.YearMonth

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
@Serializable
@Parcelize
data class SerializedYearMonth(val year: Int, val month: Int) : Parcelable {
constructor(yearMonth: YearMonth) : this(yearMonth.year, yearMonth.monthValue)

@AndroidEntryPoint
class LoadingFragment : Fragment() {
fun toYearMonth(): YearMonth = YearMonth.of(year, month)
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = FragmentAccountLoadingBinding.inflate(inflater, container, false).root

}
fun YearMonth.toSerializedYearMonth() = SerializedYearMonth(this)
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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.serialization

import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi

@OptIn(ExperimentalEncodingApi::class)
fun String.serializeForNavigation() = Base64.UrlSafe.encode(this.encodeToByteArray())

@OptIn(ExperimentalEncodingApi::class)
fun String.deserializeForNavigation() = Base64.UrlSafe.decode(this).decodeToString()
Original file line number Diff line number Diff line change
@@ -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 ) {
Original file line number Diff line number Diff line change
@@ -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,
Original file line number Diff line number Diff line change
@@ -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
data class CurrentDBProvider(var activeDB: DB?)
val CurrentDBProvider.requireDB: DB get() = activeDB ?: throw IllegalStateException("No DB provided")
Original file line number Diff line number Diff line change
@@ -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<AssociatedRecurringExpense> {
override fun createFromParcel(parcel: Parcel): AssociatedRecurringExpense {
return AssociatedRecurringExpense(parcel)
}

override fun newArray(size: Int): Array<AssociatedRecurringExpense?> {
return arrayOfNulls(size)
}
}
}
) : Parcelable
Original file line number Diff line number Diff line change
@@ -26,7 +26,7 @@ data class DataForMonth(
val daysData: Map<LocalDate, DataForDay>,
) {
companion object {
const val numberOfLeewayDays: Long = 6
const val NUMBER_OF_LEEWAY_DAYS: Long = 6
}
}

Original file line number Diff line number Diff line change
@@ -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<Expense> {
override fun createFromParcel(parcel: Parcel): Expense {
return Expense(parcel)
}

override fun newArray(size: Int): Array<Expense?> {
return arrayOfNulls(size)
}
}

}
Original file line number Diff line number Diff line change
@@ -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<RecurringExpense> {
override fun createFromParcel(parcel: Parcel): RecurringExpense {
return RecurringExpense(parcel)
}

override fun newArray(size: Int): Array<RecurringExpense?> {
return arrayOfNulls(size)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -268,6 +268,16 @@ private fun Int.toDayOfWeek(): DayOfWeek {
}
}

private lateinit var userAllowingUpdatePushesFlow: MutableStateFlow<Boolean>

fun Parameters.watchUserAllowingUpdatePushes(): StateFlow<Boolean> {
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<Boolean>

fun Parameters.watchUserAllowingDailyReminderPushes(): StateFlow<Boolean> {
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<Boolean>

fun Parameters.watchUserAllowingMonthlyReminderPushes(): StateFlow<Boolean> {
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<Boolean>

fun Parameters.watchUserSawMonthlyReportHint(): StateFlow<Boolean> {
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<AppTheme>

fun Parameters.watchTheme(): StateFlow<AppTheme> {
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<Boolean>

fun Parameters.watchIsBackupEnabled(): StateFlow<Boolean> {
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<Date?>

fun Parameters.watchLastBackupDate(): StateFlow<Date?> {
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<String?>

fun Parameters.watchLatestSelectedOnlineAccountId(): StateFlow<String?> {
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)
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -13,125 +13,132 @@
* 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<ActivityCreateAccountBinding>() {
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<CreateAccountViewModel.State>,
eventFlow: Flow<CreateAccountViewModel.Event>,
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()
}
}
}

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 = {},
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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

This file was deleted.

Loading

0 comments on commit 5fdd028

Please sign in to comment.