Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.serranoie.app.minus.presentation.ui.currency

import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import com.serranoie.app.minus.domain.currency.ConversionPreferences
import com.serranoie.app.minus.domain.currency.ExchangeRate
import com.serranoie.app.minus.presentation.ui.theme.MinusTheme
import org.junit.Rule
import org.junit.Test
import java.math.BigDecimal

class CurrencyConversionUiTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val prefs = ConversionPreferences("BRL", referenceRates = listOf(ExchangeRate("USD", "BRL", BigDecimal("5"), "2026-10-08", false)))
@Test fun showsPairedTotals() {
rule.setContent { MinusTheme { CurrencyPeriodSummaryContent("USD", BigDecimal("100"), BigDecimal("12.50"), prefs, false) } }
rule.onNodeWithText("Budget: USD 100.00 ≈ BRL 500.00").assertIsDisplayed()
rule.onNodeWithText("Spent: USD 12.50 ≈ BRL 62.50").assertIsDisplayed()
}
@Test fun missingRateIsExplicit() {
rule.setContent { MinusTheme { CurrencyPeriodSummaryContent("EUR", BigDecimal("100"), BigDecimal("12.50"), prefs, false) } }
rule.onNodeWithText("No saved EUR/BRL rate. Set a rate in Settings.").assertIsDisplayed()
}
@Test fun privacyHidesConversion() {
rule.setContent { MinusTheme { CurrencyPeriodSummaryContent("USD", BigDecimal("100"), BigDecimal("12.50"), prefs, true) } }
rule.onNodeWithText("Budget: USD 100.00 ≈ BRL 500.00").assertDoesNotExist()
}
}
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />

<!-- Notification permission for Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.serranoie.app.minus.data.currency

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.serranoie.app.minus.domain.currency.ConversionPreferences
import com.serranoie.app.minus.domain.currency.ExchangeRate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.math.BigDecimal
import java.net.URL
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Singleton
import javax.net.ssl.HttpsURLConnection

@Serializable
internal data class RateRecord(val base: String, val quote: String, val rate: String, val date: String, val manual: Boolean) {
fun domain() = ExchangeRate(base, quote, rate.toBigDecimal(), date, manual)
}
@Serializable
internal data class ConversionRecord(val displayCurrency: String? = null, val manual: List<RateRecord> = emptyList(), val reference: List<RateRecord> = emptyList()) {
fun domain() = ConversionPreferences(displayCurrency, manual.map { it.domain() }, reference.map { it.domain() })
}
@Serializable
internal data class FrankfurterRate(val date: String, val base: String, val quote: String, val rate: kotlinx.serialization.json.JsonPrimitive)

// Parse decimal literals without a lossy Double intermediate.
internal fun parseReferenceRate(body: String, base: String, quote: String, today: LocalDate = LocalDate.now()): ExchangeRate {
val rows = Json { ignoreUnknownKeys = true }.decodeFromString<List<FrankfurterRate>>(body)
val row = rows.single { it.base == base && it.quote == quote }
require(!row.rate.isString)
return ExchangeRate(row.base, row.quote, row.rate.content.toBigDecimal(), row.date, false).also {
require(!LocalDate.parse(it.date).isAfter(today))
}
}

@Singleton
class CurrencyConversionRepository @Inject constructor(private val dataStore: DataStore<Preferences>) {
private val key = stringPreferencesKey("currency_conversion_v1")
private val json = Json { ignoreUnknownKeys = true }
private fun read(raw: String?): ConversionRecord = runCatching {
json.decodeFromString<ConversionRecord>(raw ?: "{}").also { it.domain() }
}.getOrDefault(ConversionRecord())
val preferences = dataStore.data.map { read(it[key]).domain() }

suspend fun setDisplayCurrency(code: String?) {
require(code == null || code.matches(Regex("[A-Z]{3}")))
dataStore.edit { it[key] = json.encodeToString(read(it[key]).copy(displayCurrency = code)) }
}
suspend fun setManualRate(base: String, quote: String, rate: BigDecimal) {
require(rate.precision() <= 30 && rate.scale() in -18..24)
val checked = ExchangeRate(base, quote, rate, LocalDate.now().toString(), true)
val record = RateRecord(checked.base, checked.quote, checked.rate.toPlainString(), checked.date, true)
dataStore.edit {
val old = read(it[key])
// Keep only one manual override for the pair, including its inverse.
val remaining = old.manual.filterNot { r -> setOf(r.base, r.quote) == setOf(base, quote) }
it[key] = json.encodeToString(old.copy(manual = remaining + record))
}
}
suspend fun removeManualRate(base: String, quote: String) {
dataStore.edit {
val old = read(it[key])
it[key] = json.encodeToString(old.copy(manual = old.manual.filterNot { r -> r.base == base && r.quote == quote }))
}
}
suspend fun fetchReferenceRate(base: String, quote: String) {
require(base.matches(Regex("[A-Z]{3}")) && quote.matches(Regex("[A-Z]{3}")) && base != quote)
val rate = withContext(Dispatchers.IO) {
val connection = URL("https://api.frankfurter.dev/v2/rates?base=$base&quotes=$quote").openConnection() as HttpsURLConnection
connection.instanceFollowRedirects = false
connection.connectTimeout = 15000
connection.readTimeout = 15000
connection.setRequestProperty("Accept", "application/json")
try {
check(connection.responseCode == 200) { "Reference rate request failed (${connection.responseCode})" }
val body = connection.inputStream.bufferedReader().use { reader ->
val chars = CharArray(65537)
var count = 0
while (count < chars.size) {
val n = reader.read(chars, count, chars.size - count)
if (n < 0) break
count += n
}
check(count <= 65536) { "Reference rate response too large" }
String(chars, 0, count)
}
parseReferenceRate(body, base, quote)
} finally { connection.disconnect() }
}
val record = RateRecord(rate.base, rate.quote, rate.rate.toPlainString(), rate.date, false)
dataStore.edit {
val old = read(it[key])
it[key] = json.encodeToString(old.copy(reference = old.reference.filterNot { r -> setOf(r.base, r.quote) == setOf(base, quote) } + record))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.serranoie.app.minus.domain.currency

import java.math.BigDecimal
import java.math.MathContext
import java.time.LocalDate

/** Rates are display preferences, never transaction updates. */
data class ExchangeRate(
val base: String,
val quote: String,
val rate: BigDecimal,
val date: String,
val manual: Boolean,
) {
init {
require(base.matches(Regex("[A-Z]{3}")) && quote.matches(Regex("[A-Z]{3}")))
require(base != quote && rate.signum() > 0 && rate.precision() <= 40)
require(rate.scale() in -40..100)
LocalDate.parse(date)
}
val key: String get() = "$base/$quote"
}

data class ConversionPreferences(
val displayCurrency: String? = null,
val manualRates: List<ExchangeRate> = emptyList(),
val referenceRates: List<ExchangeRate> = emptyList(),
) {
fun rateFor(base: String, quote: String): ExchangeRate? {
fun find(rates: List<ExchangeRate>): ExchangeRate? {
rates.firstOrNull { it.base == base && it.quote == quote }?.let { return it }
return rates.firstOrNull { it.base == quote && it.quote == base }?.let {
it.copy(base = base, quote = quote, rate = BigDecimal.ONE.divide(it.rate, MathContext.DECIMAL128))
}
}
// A manual rate in either direction always wins over a reference rate.
return find(manualRates) ?: find(referenceRates)
}
fun convert(amount: BigDecimal, base: String): BigDecimal? {
val quote = displayCurrency ?: return null
if (base == quote) return amount
return rateFor(base, quote)?.let { amount.multiply(it.rate, MathContext.DECIMAL128) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,14 @@ fun Analytics(
}

Spacer(modifier = Modifier.height(16.dp))
if (shown.budgetSettingsForDisplay != null) {
com.serranoie.app.minus.presentation.ui.currency.CurrencyPeriodSummary(
base = shown.currencyCode,
budget = shown.wholeBudget,
spent = shown.budgetStateForDisplay?.totalSpentInPeriod ?: shown.spends.sumOf { it.amount },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
BudgetGraph(
state = shown,
onGranularityChanged = actions.onGranularityChanged,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.serranoie.app.minus.presentation.ui.analytics

import com.serranoie.app.minus.presentation.ui.currency.CurrencyConversionHost
import androidx.activity.compose.BackHandler
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.compose.runtime.Composable
Expand Down Expand Up @@ -39,46 +40,48 @@ fun AnalyticsScreen(
}
}

Analytics(
state = uiState.displayState,
archivedBudgets = uiState.archivedBudgets,
categories = uiState.categories,
actions = AnalyticsActions(
onCreateNewPeriod = {
viewModel.onCreateNewPeriod()
},
onClose = {
viewModel.onClose()
},
onNavigateToSubscriptions = onNavigateToSubscriptions,
onMarkCreditPaid = {
viewModel.onMarkCreditPaid()
},
onPayTransactionClick = { txId ->
viewModel.onPayTransactionClick(txId)
},
onCutoffDayChanged = { day ->
viewModel.onCutoffDayChanged(day)
},
onHistoricalPeriodSelected = { periodId ->
viewModel.onPeriodSelected(periodId)
},
onTutorialCompleted = { hasSpends ->
viewModel.onTutorialCompleted(hasSpends)
},
onGranularityChanged = { granularity ->
viewModel.onGranularityChanged(granularity)
},
onUpdateTransaction = { tx ->
viewModel.updateTransaction(tx)
},
onDeleteTransaction = { tx ->
viewModel.deleteTransaction(tx)
},
onDeleteArchivedPeriod = { periodId ->
viewModel.deleteArchivedPeriod(periodId)
},
),
activityResultRegistryOwner = activityResultRegistryOwner,
)
CurrencyConversionHost {
Analytics(
state = uiState.displayState,
archivedBudgets = uiState.archivedBudgets,
categories = uiState.categories,
actions = AnalyticsActions(
onCreateNewPeriod = {
viewModel.onCreateNewPeriod()
},
onClose = {
viewModel.onClose()
},
onNavigateToSubscriptions = onNavigateToSubscriptions,
onMarkCreditPaid = {
viewModel.onMarkCreditPaid()
},
onPayTransactionClick = { txId ->
viewModel.onPayTransactionClick(txId)
},
onCutoffDayChanged = { day ->
viewModel.onCutoffDayChanged(day)
},
onHistoricalPeriodSelected = { periodId ->
viewModel.onPeriodSelected(periodId)
},
onTutorialCompleted = { hasSpends ->
viewModel.onTutorialCompleted(hasSpends)
},
onGranularityChanged = { granularity ->
viewModel.onGranularityChanged(granularity)
},
onUpdateTransaction = { tx ->
viewModel.updateTransaction(tx)
},
onDeleteTransaction = { tx ->
viewModel.deleteTransaction(tx)
},
onDeleteArchivedPeriod = { periodId ->
viewModel.deleteArchivedPeriod(periodId)
},
),
activityResultRegistryOwner = activityResultRegistryOwner,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.serranoie.app.minus.presentation.ui.currency

import com.serranoie.app.minus.domain.model.SupportedCurrency
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.Locale

internal fun formatConversionAmount(amount: BigDecimal, currency: String, locale: Locale): String {
val digits = (SupportedCurrency.findByCode(currency)?.defaultFractionDigits ?: 2).coerceAtLeast(0)
val rounded = amount.setScale(digits, RoundingMode.HALF_EVEN)
val formatter = NumberFormat.getNumberInstance(locale).apply {
minimumFractionDigits = digits
maximumFractionDigits = digits
roundingMode = RoundingMode.HALF_EVEN
}
return "$currency ${formatter.format(rounded)}"
}
Loading