Skip to content

Commit e427e9a

Browse files
committed
feat(opencode): overhaul Fiat formatting to properly handle symbols when possible
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent eb520d3 commit e427e9a

5 files changed

Lines changed: 126 additions & 64 deletions

File tree

services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/OpenCodeExchange.kt

Lines changed: 9 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
package com.getcode.opencode.internal.exchange
22

33
import com.getcode.opencode.exchange.Exchange
4+
import com.getcode.opencode.internal.extensions.fromCode
5+
import com.getcode.opencode.internal.extensions.getClosestLocale
46
import com.getcode.opencode.internal.network.services.CurrencyService
57
import com.getcode.opencode.model.financial.Currency
68
import com.getcode.opencode.model.financial.CurrencyCode
79
import com.getcode.opencode.model.financial.Rate
8-
import com.getcode.opencode.model.financial.RegionCode
910
import com.getcode.services.opencode.R
1011
import com.getcode.util.format
1112
import com.getcode.util.locale.LocaleHelper
@@ -17,17 +18,13 @@ import com.getcode.utils.trace
1718
import kotlinx.coroutines.CoroutineScope
1819
import kotlinx.coroutines.Dispatchers
1920
import kotlinx.coroutines.async
20-
import kotlinx.coroutines.awaitAll
2121
import kotlinx.coroutines.flow.Flow
2222
import kotlinx.coroutines.flow.MutableStateFlow
2323
import kotlinx.coroutines.launch
24-
import kotlinx.coroutines.runBlocking
2524
import kotlinx.coroutines.withContext
2625
import kotlinx.datetime.Clock
2726
import kotlinx.datetime.Instant
2827
import java.util.Date
29-
import java.util.Locale
30-
import java.util.concurrent.ConcurrentHashMap
3128
import javax.inject.Inject
3229
import kotlin.time.Duration
3330
import kotlin.time.Duration.Companion.minutes
@@ -38,16 +35,6 @@ internal class OpenCodeExchange @Inject constructor(
3835
private val locale: LocaleHelper,
3936
) : Exchange, CoroutineScope by CoroutineScope(Dispatchers.IO) {
4037

41-
private val currencies: List<Currency> by lazy {
42-
runBlocking {
43-
initCurrencies()
44-
}
45-
}
46-
47-
private val currenciesMap: Map<String, Currency> by lazy {
48-
currencies.associateBy { it.code }
49-
}
50-
5138
private val _balanceRate = MutableStateFlow(Rate.oneToOne)
5239
override val balanceRate
5340
get() = _balanceRate.value
@@ -100,18 +87,18 @@ internal class OpenCodeExchange @Inject constructor(
10087

10188
override suspend fun getCurrenciesWithRates(rates: Map<CurrencyCode, Rate>): List<Currency> =
10289
withContext(Dispatchers.Default) {
103-
return@withContext currencies
104-
.mapNotNull {
105-
val code = CurrencyCode.tryValueOf(it.code) ?: return@mapNotNull null
90+
return@withContext CurrencyCode.entries
91+
.mapNotNull { code ->
10692
val rate = rates[code]?.fx ?: 0.0
107-
it.copy(rate = rate)
93+
getCurrencyWithRate(code.name, rate)
10894
}
10995
}
11096

111-
override fun getCurrency(code: String): Currency? = currenciesMap[code.uppercase()]
97+
override fun getCurrency(code: String): Currency? =
98+
CurrencyCode.tryValueOf(code)?.let { Currency.fromCode(it, resources) }
11299

113100
override fun getCurrencyWithRate(code: String, rate: Double): Currency? =
114-
currenciesMap[code.uppercase()]?.copy(rate = rate)
101+
getCurrency(code)?.copy(rate = rate)
115102

116103
override fun getFlagByCurrency(currencyCode: String?): Int? {
117104
currencyCode ?: return null
@@ -258,14 +245,10 @@ internal class OpenCodeExchange @Inject constructor(
258245
}
259246
}
260247

261-
private fun getLocale(region: RegionCode?): Locale {
262-
return Locale(Locale.getDefault().language, region?.name.orEmpty())
263-
}
264-
265248
private suspend fun getCurrency(code: CurrencyCode, scope: CoroutineScope): Currency {
266249
val resId = scope.async { getFlagByCurrency(code.name) }
267250
val currencyJava = scope.async { java.util.Currency.getInstance(code.name) }
268-
val locale = scope.async { getLocale(code.getRegion()) }
251+
val locale = scope.async { code.getClosestLocale() }
269252

270253
return Currency(
271254
code = currencyJava.await().currencyCode,
@@ -274,35 +257,6 @@ internal class OpenCodeExchange @Inject constructor(
274257
symbol = currencyJava.await().getSymbol(locale.await())
275258
)
276259
}
277-
278-
private suspend fun initCurrencies(): List<Currency> {
279-
val scope = CoroutineScope(Dispatchers.Default)
280-
281-
val currencyMap = ConcurrentHashMap<String, Currency>()
282-
283-
val chunkSize = 25
284-
val chunks = CurrencyCode.entries.chunked(chunkSize)
285-
286-
// Process each chunk asynchronously
287-
val jobs = chunks.map { chunk ->
288-
scope.async {
289-
chunk.forEach { currencyCode ->
290-
try {
291-
val currency = getCurrency(currencyCode, scope)
292-
currencyMap[currency.name] = currency
293-
} catch (_: Exception) {
294-
// Handle exceptions if needed
295-
}
296-
}
297-
}
298-
}
299-
300-
// Wait for all jobs to complete
301-
jobs.awaitAll()
302-
303-
// Sort the currencies by name
304-
return currencyMap.values.sortedBy { it.name }
305-
}
306260
}
307261

308262
private data class RatesBox(val dateMillis: Long, val rates: Map<CurrencyCode, Rate>) {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.getcode.opencode.internal.extensions
2+
3+
import com.getcode.opencode.model.financial.Currency
4+
import com.getcode.opencode.model.financial.CurrencyCode
5+
import com.getcode.util.resources.ResourceHelper
6+
import com.getcode.util.resources.ResourceType
7+
8+
fun Currency.Companion.fromCode(code: CurrencyCode, resources: ResourceHelper): Currency {
9+
val resId = resources.getFlagByCurrency(code.getRegion()?.name.orEmpty())
10+
val currencyJava = java.util.Currency.getInstance(code.name)
11+
12+
return Currency(
13+
code = currencyJava.currencyCode,
14+
name = currencyJava.displayName,
15+
resId = resId,
16+
symbol = code.singleCharacterCurrencySymbol.orEmpty()
17+
)
18+
}
19+
20+
private fun ResourceHelper.getFlagByCurrency(countryCode: String): Int? {
21+
if (countryCode.isEmpty()) return null
22+
val resourceName = "ic_flag_${countryCode.lowercase()}"
23+
return getIdentifier(
24+
resourceName,
25+
ResourceType.Drawable
26+
).let { if (it == 0) null else it }
27+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.getcode.opencode.internal.extensions
2+
3+
import android.icu.util.Currency
4+
import com.getcode.opencode.model.financial.CurrencyCode
5+
import com.getcode.utils.trace
6+
import java.util.Locale
7+
8+
internal fun CurrencyCode.getClosestLocale(): Locale {
9+
val currency = Currency.getInstance(name.uppercase())
10+
11+
val locales = Locale.getAvailableLocales()
12+
var matchedLocale: Locale? = null
13+
14+
val customLanguageMap = mapOf(
15+
"csw" to "en", // Map Swampy Cree (csw) to English for locale purposes
16+
)
17+
18+
for (locale in locales) {
19+
if (locale.country.equals(getRegion()?.name, ignoreCase = true)) {
20+
try {
21+
val localeCurrency = Currency.getInstance(locale)
22+
if (localeCurrency.currencyCode.lowercase() == currency.currencyCode.lowercase()) {
23+
matchedLocale = locale
24+
break
25+
}
26+
} catch (e: IllegalArgumentException) {
27+
// Some locales may not have a currency; skip them
28+
continue
29+
}
30+
}
31+
}
32+
33+
println("Matched locale: $matchedLocale")
34+
35+
val language = customLanguageMap[matchedLocale?.language] ?: matchedLocale?.language
36+
val country = matchedLocale?.country
37+
return try {
38+
Locale("${language}_${country}")
39+
} catch (e: IllegalArgumentException) {
40+
trace("Failed to get locale for $name")
41+
locale()
42+
}
43+
}
44+
45+
private fun CurrencyCode.locale() = Locale(Locale.getDefault().language, getRegion()?.name.orEmpty())

services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/CurrencyCode.kt

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package com.getcode.opencode.model.financial
22

3+
import com.getcode.opencode.internal.extensions.getClosestLocale
4+
import java.util.Currency
5+
36
enum class CurrencyCode {
47
AED,
58
AFN,
@@ -322,7 +325,24 @@ enum class CurrencyCode {
322325
}
323326
}
324327

325-
val currenciesRegions: Map<CurrencyCode, RegionCode?> = mapOf(
328+
private val lookupTable: Map<CurrencyCode, Set<String>> by lazy {
329+
buildMap {
330+
CurrencyCode.entries.forEach { currency ->
331+
val locale = currency.getClosestLocale()
332+
try {
333+
val currencyInstance = Currency.getInstance(currency.name)
334+
val symbol = currencyInstance.getSymbol(locale)
335+
if (symbol != null) {
336+
put(currency, setOf(symbol))
337+
}
338+
} catch (e: IllegalArgumentException) {
339+
// Skip currencies with no valid symbol
340+
}
341+
}
342+
}
343+
}
344+
345+
private val currenciesRegions: Map<CurrencyCode, RegionCode?> = mapOf(
326346
USD to RegionCode.US,
327347
EUR to RegionCode.EU,
328348
CHF to RegionCode.CH,
@@ -483,5 +503,11 @@ enum class CurrencyCode {
483503
.mapNotNull { p -> p.value?.let { v -> Pair(v, p.key) } }
484504
.toMap()
485505
}
506+
507+
val currencySymbols: List<String>
508+
get() = lookupTable[this]?.toList()?.sortedBy { it.length } ?: emptyList()
509+
510+
val singleCharacterCurrencySymbol: String?
511+
get() = lookupTable[this]?.firstOrNull { it.length == 1 }
486512
}
487513

services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.getcode.opencode.model.financial
22

3-
import android.icu.util.Currency
3+
import android.icu.util.ULocale
44
import kotlinx.serialization.Serializable
55
import java.math.RoundingMode
66
import java.text.DecimalFormat
@@ -45,13 +45,23 @@ data class Fiat(
4545

4646
// Formatting
4747
fun formatted(suffix: String? = null, truncated: Boolean = false): String {
48-
val formatter = android.icu.text.DecimalFormat.getCurrencyInstance(Locale.getDefault()).apply {
49-
currency = Currency.getInstance(currencyCode.name)
50-
maximumFractionDigits = if (truncated) 0 else 2
51-
roundingMode = RoundingMode.DOWN.ordinal
48+
val formatter = android.icu.text.DecimalFormat.getInstance(ULocale.US).apply {
49+
minimumFractionDigits = 2
50+
maximumFractionDigits = 2
51+
roundingMode = if (truncated) RoundingMode.DOWN.ordinal else RoundingMode.HALF_DOWN.ordinal
52+
(this as android.icu.text.DecimalFormat).decimalFormatSymbols = decimalFormatSymbols.apply {
53+
currencySymbol = ""
54+
}
55+
56+
val prefix = currencyCode.singleCharacterCurrencySymbol.orEmpty()
57+
58+
positivePrefix = prefix
59+
negativePrefix = prefix
60+
positiveSuffix = suffix?.prependIndent(" ").orEmpty()
61+
negativeSuffix = suffix?.prependIndent(" ").orEmpty()
5262
}
53-
val formattedValue = formatter.format(decimalValue)
54-
return if (suffix != null) "$formattedValue $suffix" else formattedValue
63+
64+
return formatter.format(decimalValue)
5565
}
5666

5767
// String representation
@@ -108,4 +118,4 @@ fun Number.toFiat(currencyCode: CurrencyCode = CurrencyCode.USD): Fiat = when (t
108118
is Long -> Fiat(this.toULong(), currencyCode)
109119
is Double -> Fiat(this, currencyCode)
110120
else -> throw IllegalArgumentException("Unsupported number type")
111-
}
121+
}

0 commit comments

Comments
 (0)