Skip to content

Commit 5e05de0

Browse files
committed
feat: introduced unified currency mode, with options to set custom currency and exchange rates #83,#113, #116, #132
1 parent febb718 commit 5e05de0

57 files changed

Lines changed: 2043 additions & 578 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app/schemas/com.ritesh.cashiro.data.database.CashiroDatabase/52.json‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"formatVersion": 1,
33
"database": {
44
"version": 52,
5-
"identityHash": "7af7c786b43a1ea50bbb1592e2df5bfb",
5+
"identityHash": "8cdd5bb3c827a51203352bce87918a53",
66
"entities": [
77
{
88
"tableName": "transactions",
@@ -313,7 +313,7 @@
313313
},
314314
{
315315
"tableName": "merchant_mappings",
316-
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`merchant_name` TEXT NOT NULL, `category` TEXT NOT NULL, `subcategory` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`merchant_name`))",
316+
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`merchant_name` TEXT NOT NULL, `category` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`merchant_name`))",
317317
"fields": [
318318
{
319319
"fieldPath": "merchantName",
@@ -327,11 +327,6 @@
327327
"affinity": "TEXT",
328328
"notNull": true
329329
},
330-
{
331-
"fieldPath": "subcategory",
332-
"columnName": "subcategory",
333-
"affinity": "TEXT"
334-
},
335330
{
336331
"fieldPath": "createdAt",
337332
"columnName": "created_at",
@@ -1004,7 +999,7 @@
1004999
},
10051000
{
10061001
"tableName": "exchange_rates",
1007-
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `from_currency` TEXT NOT NULL, `to_currency` TEXT NOT NULL, `rate` TEXT NOT NULL, `provider` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `updated_at_unix` INTEGER NOT NULL DEFAULT 0, `expires_at` TEXT NOT NULL, `expires_at_unix` INTEGER NOT NULL DEFAULT 0)",
1002+
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `from_currency` TEXT NOT NULL, `to_currency` TEXT NOT NULL, `rate` TEXT NOT NULL, `provider` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `updated_at_unix` INTEGER NOT NULL DEFAULT 0, `expires_at` TEXT NOT NULL, `expires_at_unix` INTEGER NOT NULL DEFAULT 0, `is_custom` INTEGER NOT NULL DEFAULT 0)",
10081003
"fields": [
10091004
{
10101005
"fieldPath": "id",
@@ -1061,6 +1056,13 @@
10611056
"affinity": "INTEGER",
10621057
"notNull": true,
10631058
"defaultValue": "0"
1059+
},
1060+
{
1061+
"fieldPath": "isCustom",
1062+
"columnName": "is_custom",
1063+
"affinity": "INTEGER",
1064+
"notNull": true,
1065+
"defaultValue": "0"
10641066
}
10651067
],
10661068
"primaryKey": {
@@ -1691,7 +1693,7 @@
16911693
],
16921694
"setupQueries": [
16931695
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
1694-
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7af7c786b43a1ea50bbb1592e2df5bfb')"
1696+
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8cdd5bb3c827a51203352bce87918a53')"
16951697
]
16961698
}
16971699
}

‎app/src/main/java/com/ritesh/cashiro/MainActivity.kt‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import androidx.appcompat.app.AppCompatActivity
1414
import com.ritesh.cashiro.receiver.SmsBroadcastReceiver
1515
import com.ritesh.cashiro.data.manager.NotificationScheduler
1616
import androidx.lifecycle.lifecycleScope
17+
import com.ritesh.cashiro.data.currency.model.CurrencySymbols
18+
import com.ritesh.cashiro.data.preferences.UserPreferencesRepository
1719
import com.ritesh.cashiro.presentation.ui.features.accounts.AccountDetailViewModel
1820
import com.ritesh.cashiro.presentation.ui.features.accounts.ManageAccountsViewModel
1921
import com.ritesh.cashiro.presentation.ui.features.add.AddViewModel
@@ -53,6 +55,9 @@ class MainActivity : AppCompatActivity() {
5355
@Inject
5456
lateinit var notificationScheduler: NotificationScheduler
5557

58+
@Inject
59+
lateinit var userPreferencesRepository: UserPreferencesRepository
60+
5661
// Transaction ID to edit when launched from notification
5762
var editTransactionId by mutableStateOf<Long?>(null)
5863
private set
@@ -89,6 +94,14 @@ class MainActivity : AppCompatActivity() {
8994
notificationScheduler.scheduleDailyReminder()
9095
}
9196

97+
// Load custom currencies into CurrencySymbols
98+
lifecycleScope.launch {
99+
userPreferencesRepository.customCurrencies.collect { customCurrencies ->
100+
val customMap = customCurrencies.associate { it.code to it.symbol }
101+
CurrencySymbols.setCustomSymbols(customMap)
102+
}
103+
}
104+
92105
setContent {
93106
CashiroApp(
94107
editTransactionId = editTransactionId,

‎app/src/main/java/com/ritesh/cashiro/data/backup/BackupExporter.kt‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ class BackupExporter @Inject constructor(
121121
val budgetCategoryLimits = if (config.includeBudgets) database.budgetDao().getAllCategoryLimits().first() else emptyList()
122122
val subcategories = if (config.includeProfileData) database.subcategoryDao().getAllSubcategories().first() else emptyList()
123123
val rules = if (config.includeAppPreferences) database.ruleDao().getAllRules().first() else emptyList()
124+
val exchangeRates = if (config.includeAppPreferences) database.exchangeRateDao().getAllRates().first() else emptyList()
124125
val ruleApplications = if (config.includeTransactionalData) database.ruleApplicationDao().getRecentApplications(1000).first() else emptyList() // Limit to recent apps for backup size
125126
val webhookProfiles = if (config.includeAppPreferences) {
126127
database.webhookProfileDao().getAllProfiles().first().map { profile ->
@@ -204,7 +205,8 @@ class BackupExporter @Inject constructor(
204205
subcategories = subcategories,
205206
rules = rules,
206207
ruleApplications = ruleApplications,
207-
webhookProfiles = webhookProfiles
208+
webhookProfiles = webhookProfiles,
209+
exchangeRates = exchangeRates
208210
),
209211
preferences = PreferencesSnapshot(
210212
theme = ThemePreferences(
@@ -250,6 +252,20 @@ class BackupExporter @Inject constructor(
250252
order = order.map { it.name },
251253
hidden = hidden.map { it.name }
252254
)
255+
} else null,
256+
currency = if (config.includeAppPreferences) {
257+
val unifiedCurrencyEnabled = userPreferencesRepository.unifiedCurrencyEnabled.first()
258+
val unifiedCurrencyCode = userPreferencesRepository.unifiedCurrencyCode.first()
259+
val defaultCurrencyEnabled = userPreferencesRepository.defaultCurrencyEnabled.first()
260+
val defaultCurrencyCode = userPreferencesRepository.defaultCurrencyCode.first()
261+
val customCurrencies = userPreferencesRepository.customCurrencies.first()
262+
CurrencyPreferences(
263+
unifiedCurrencyEnabled = unifiedCurrencyEnabled,
264+
unifiedCurrencyCode = unifiedCurrencyCode,
265+
defaultCurrencyEnabled = defaultCurrencyEnabled,
266+
defaultCurrencyCode = defaultCurrencyCode,
267+
customCurrencies = customCurrencies
268+
)
253269
} else null
254270
)
255271
)

‎app/src/main/java/com/ritesh/cashiro/data/backup/BackupImporter.kt‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ class BackupImporter @Inject constructor(
172172
}
173173
database.ruleDao().deleteAllRules()
174174
database.ruleApplicationDao().deleteAllApplications()
175+
database.exchangeRateDao().deleteAllRates()
175176

176177
// Import all data
177178
backup.database.categories.forEach { category ->
@@ -208,6 +209,10 @@ class BackupImporter @Inject constructor(
208209
database.chatDao().insertMessage(message)
209210
}
210211

212+
backup.database.exchangeRates.forEach { rate ->
213+
database.exchangeRateDao().insertExchangeRate(rate)
214+
}
215+
211216
backup.database.budgets.forEach { budget ->
212217
database.budgetDao().insertBudget(budget.sanitize())
213218
}
@@ -366,6 +371,7 @@ class BackupImporter @Inject constructor(
366371
importMerchantMappingsWithMerge(backup.database.merchantMappings)
367372
importBudgetsWithMerge(backup.database.budgets, backup.database.budgetCategoryLimits)
368373
importWebhookProfilesWithMerge(backup.database.webhookProfiles)
374+
importExchangeRatesWithMerge(backup.database.exchangeRates)
369375

370376
// Import preferences (merge with existing)
371377
importPreferences(backup.preferences)
@@ -487,6 +493,12 @@ class BackupImporter @Inject constructor(
487493
}
488494
}
489495

496+
private suspend fun importExchangeRatesWithMerge(rates: List<ExchangeRateEntity>) {
497+
rates.forEach { rate ->
498+
database.exchangeRateDao().insertExchangeRate(rate)
499+
}
500+
}
501+
490502
private fun parseRangePreset(value: String): WebhookRangePreset =
491503
runCatching { WebhookRangePreset.valueOf(value) }
492504
.getOrElse { WebhookRangePreset.SINCE_LAST_SUCCESS }
@@ -642,5 +654,20 @@ class BackupImporter @Inject constructor(
642654

643655
userPreferencesRepository.updateShowBannerImage(profile.showBannerImage)
644656
}
657+
658+
// Currency Preferences
659+
preferences.currency?.let { currencyPrefs ->
660+
userPreferencesRepository.setUnifiedCurrencyEnabled(currencyPrefs.unifiedCurrencyEnabled)
661+
currencyPrefs.unifiedCurrencyCode?.let {
662+
userPreferencesRepository.setUnifiedCurrencyCode(it)
663+
}
664+
userPreferencesRepository.setDefaultCurrencyEnabled(currencyPrefs.defaultCurrencyEnabled)
665+
currencyPrefs.defaultCurrencyCode?.let {
666+
userPreferencesRepository.setDefaultCurrencyCode(it)
667+
}
668+
currencyPrefs.customCurrencies.forEach { customCurrency ->
669+
userPreferencesRepository.addCustomCurrency(customCurrency)
670+
}
671+
}
645672
}
646673
}

‎app/src/main/java/com/ritesh/cashiro/data/backup/BackupModels.kt‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,10 @@ data class DatabaseSnapshot(
132132
val webhookProfiles: List<WebhookProfileBackup> = emptyList(),
133133

134134
@SerializedName("chat_messages")
135-
val chatMessages: List<ChatMessage>
135+
val chatMessages: List<ChatMessage>,
136+
137+
@SerializedName("exchange_rates")
138+
val exchangeRates: List<ExchangeRateEntity> = emptyList()
136139
)
137140

138141
data class WebhookProfileBackup(
@@ -178,7 +181,10 @@ data class PreferencesSnapshot(
178181
val profile: ProfilePreferences? = null,
179182

180183
@SerializedName("home_widgets")
181-
val homeWidgets: HomeWidgetPreferences? = null
184+
val homeWidgets: HomeWidgetPreferences? = null,
185+
186+
@SerializedName("currency")
187+
val currency: CurrencyPreferences? = null
182188
)
183189

184190
/**
@@ -192,6 +198,26 @@ data class HomeWidgetPreferences(
192198
val hidden: List<String>
193199
)
194200

201+
/**
202+
* Currency preferences
203+
*/
204+
data class CurrencyPreferences(
205+
@SerializedName("unified_currency_enabled")
206+
val unifiedCurrencyEnabled: Boolean,
207+
208+
@SerializedName("unified_currency_code")
209+
val unifiedCurrencyCode: String?,
210+
211+
@SerializedName("default_currency_enabled")
212+
val defaultCurrencyEnabled: Boolean,
213+
214+
@SerializedName("default_currency_code")
215+
val defaultCurrencyCode: String?,
216+
217+
@SerializedName("custom_currencies")
218+
val customCurrencies: List<com.ritesh.cashiro.data.model.CustomCurrency> = emptyList()
219+
)
220+
195221
/**
196222
* Theme-related preferences
197223
*/

‎app/src/main/java/com/ritesh/cashiro/data/currency/CurrencyConversionService.kt‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import com.ritesh.cashiro.data.database.entity.ExchangeRateEntity
55
import com.ritesh.cashiro.data.preferences.UserPreferencesRepository
66
import kotlinx.coroutines.CoroutineScope
77
import kotlinx.coroutines.Dispatchers
8+
import kotlinx.coroutines.flow.MutableStateFlow
9+
import kotlinx.coroutines.flow.StateFlow
10+
import kotlinx.coroutines.flow.asStateFlow
811
import kotlinx.coroutines.flow.first
912
import kotlinx.coroutines.launch
1013
import java.math.BigDecimal
@@ -28,6 +31,10 @@ class CurrencyConversionService @Inject constructor(
2831
private val rateCache = mutableMapOf<String, BigDecimal>()
2932
private var lastCacheUpdate: LocalDateTime = LocalDateTime.MIN
3033

34+
// Emits a new value whenever a custom rate is saved or reset, so ViewModels can react
35+
private val _rateChangeTrigger = MutableStateFlow(0L)
36+
val rateChangeTrigger: StateFlow<Long> = _rateChangeTrigger.asStateFlow()
37+
3138
/**
3239
* Convert amount from one currency to another
3340
*/
@@ -64,6 +71,25 @@ class CurrencyConversionService @Inject constructor(
6471
rateCache[cacheKey]?.let { return it }
6572
}
6673

74+
// Check database for custom rates first (always takes priority)
75+
if (!forceRefresh) {
76+
val customRate = exchangeRateDao.getCustomRate(fromCurrency.uppercase(), toCurrency.uppercase())
77+
if (customRate != null) {
78+
updateCache(cacheKey, customRate.rate)
79+
return customRate.rate
80+
}
81+
82+
val reverseCustomRate = exchangeRateDao.getCustomRate(toCurrency.uppercase(), fromCurrency.uppercase())
83+
if (reverseCustomRate != null) {
84+
try {
85+
val invertedRate = BigDecimal.ONE.divide(reverseCustomRate.rate, MathContext(10))
86+
updateCache(cacheKey, invertedRate)
87+
return invertedRate
88+
} catch (_: ArithmeticException) {
89+
}
90+
}
91+
}
92+
6793
// Check database for fresh rates
6894
val currentTime = LocalDateTime.now()
6995
val dbRate = exchangeRateDao.getExchangeRate(fromCurrency, toCurrency, currentTime)
@@ -93,6 +119,18 @@ class CurrencyConversionService @Inject constructor(
93119
}
94120
}
95121

122+
// Check reverse pair (e.g., custom rate stored as INR→USD when looking for USD→INR)
123+
val reverseRate = exchangeRateDao.getExchangeRate(toCurrency, fromCurrency, currentTime)
124+
if (reverseRate != null && !forceRefresh) {
125+
try {
126+
val invertedRate = BigDecimal.ONE.divide(reverseRate.rate, MathContext(10))
127+
updateCache(cacheKey, invertedRate)
128+
return invertedRate
129+
} catch (_: ArithmeticException) {
130+
// Division by zero or non-terminating decimal — fall through to API
131+
}
132+
}
133+
96134
// Fetch from API if not found, forced refresh, or rates are stale
97135
return fetchAndCacheRate(fromCurrency, toCurrency)
98136
}
@@ -185,11 +223,44 @@ class CurrencyConversionService @Inject constructor(
185223
}
186224

187225
if (entities.isNotEmpty()) {
188-
exchangeRateDao.insertExchangeRates(entities)
226+
val customRates = exchangeRateDao.getCustomRatesForCurrency(baseCurrency.uppercase())
227+
val customPairs = customRates.map { it.fromCurrency.uppercase() to it.toCurrency.uppercase() }.toSet()
228+
229+
val filteredEntities = entities.filterNot { entity ->
230+
(entity.fromCurrency.uppercase() to entity.toCurrency.uppercase()) in customPairs
231+
}
232+
233+
if (filteredEntities.isNotEmpty()) {
234+
exchangeRateDao.insertExchangeRates(filteredEntities)
235+
}
189236
}
190237
}
191238
}
192239

240+
suspend fun saveCustomRate(fromCurrency: String, toCurrency: String, rate: BigDecimal) {
241+
val now = LocalDateTime.now()
242+
val entity = ExchangeRateEntity(
243+
fromCurrency = fromCurrency.uppercase(),
244+
toCurrency = toCurrency.uppercase(),
245+
rate = rate,
246+
provider = "custom",
247+
updatedAt = now,
248+
updatedAtUnix = now.atZone(ZoneId.systemDefault()).toEpochSecond(),
249+
expiresAt = now.plusYears(100),
250+
expiresAtUnix = now.plusYears(100).atZone(ZoneId.systemDefault()).toEpochSecond(),
251+
isCustom = true
252+
)
253+
exchangeRateDao.upsertCustomRate(entity)
254+
rateCache.clear()
255+
_rateChangeTrigger.value++
256+
}
257+
258+
suspend fun resetCustomRate(fromCurrency: String, toCurrency: String) {
259+
exchangeRateDao.resetCustomRate(fromCurrency.uppercase(), toCurrency.uppercase())
260+
rateCache.clear()
261+
_rateChangeTrigger.value++
262+
}
263+
193264
/**
194265
* Retrieve stored conversions for a base currency from the local database.
195266
*/

‎app/src/main/java/com/ritesh/cashiro/data/currency/model/CurrencyConversion.kt‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package com.ritesh.cashiro.data.currency.model
33
data class CurrencyConversion(
44
val currencyCode: String,
55
val rate: Double,
6-
val lastUpdated: Long = 0L
6+
val lastUpdated: Long = 0L,
7+
val isCustom: Boolean = false,
8+
val customSymbol: String? = null
79
) {
8-
val symbol: String get() = CurrencySymbols.getSymbol(currencyCode)
10+
val symbol: String get() = customSymbol ?: CurrencySymbols.getSymbol(currencyCode)
911
val displayRate: String get() = String.format("%.6f", rate)
1012
}

0 commit comments

Comments
 (0)