From d450251094bc9ce631e559fe07e5e68356550eeb Mon Sep 17 00:00:00 2001 From: Radmir Date: Wed, 8 Apr 2026 10:42:00 +0500 Subject: [PATCH 1/3] Add price/percentage suggestions and asset info to price alert scene - Add PriceSuggestionsFormatter and PercentageSuggestionsFormatter with tests - Show suggestion chips (TabsBar) when input is empty, confirm button when filled - Move currency symbol to left of input for price type alerts - Display asset info with price and 24h change below current price - Wire new ViewModel flows for suggestions, asset, and price state --- .../presents/PriceAlertTargetNavScreen.kt | 12 ++ .../presents/PriceAlertTargetScene.kt | 135 ++++++++++++------ .../viewmodels/PriceAlertTargetViewModel.kt | 39 +++++ .../model/PercentageSuggestionsFormatter.kt | 13 ++ .../model/PriceSuggestionsFormatter.kt | 32 +++++ .../PercentageSuggestionsFormatterTest.kt | 37 +++++ .../model/PriceSuggestionsFormatterTest.kt | 50 +++++++ 7 files changed, 278 insertions(+), 40 deletions(-) create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatter.kt create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatter.kt create mode 100644 android/gemcore/src/test/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatterTest.kt create mode 100644 android/gemcore/src/test/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatterTest.kt diff --git a/android/features/settings/price_alerts/presents/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/presents/PriceAlertTargetNavScreen.kt b/android/features/settings/price_alerts/presents/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/presents/PriceAlertTargetNavScreen.kt index d757b4553..6ce66fa76 100644 --- a/android/features/settings/price_alerts/presents/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/presents/PriceAlertTargetNavScreen.kt +++ b/android/features/settings/price_alerts/presents/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/presents/PriceAlertTargetNavScreen.kt @@ -25,6 +25,12 @@ fun PriceAlertTargetNavScreen( val type by viewModel.type.collectAsStateWithLifecycle() val direction by viewModel.direction.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() + val priceSuggestions by viewModel.priceSuggestions.collectAsStateWithLifecycle() + val percentageSuggestions by viewModel.percentageSuggestions.collectAsStateWithLifecycle() + val asset by viewModel.asset.collectAsStateWithLifecycle() + val priceFormatted by viewModel.priceFormatted.collectAsStateWithLifecycle() + val priceChangeFormatted by viewModel.priceChangeFormatted.collectAsStateWithLifecycle() + val priceState by viewModel.priceState.collectAsStateWithLifecycle() PriceAlertTargetScene( value = viewModel.value, @@ -33,6 +39,12 @@ fun PriceAlertTargetNavScreen( currency = currency, currentPriceValue = currentPriceValue, currentPriceFormatted = currentPriceFormatted, + priceSuggestions = priceSuggestions, + percentageSuggestions = percentageSuggestions, + asset = asset, + assetPriceFormatted = priceFormatted, + assetPriceChangeFormatted = priceChangeFormatted, + assetPriceState = priceState, error = error, onType = viewModel::onType, onDirection = viewModel::onDirection, diff --git a/android/features/settings/price_alerts/presents/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/presents/PriceAlertTargetScene.kt b/android/features/settings/price_alerts/presents/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/presents/PriceAlertTargetScene.kt index d4dc02a09..37fc22525 100644 --- a/android/features/settings/price_alerts/presents/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/presents/PriceAlertTargetScene.kt +++ b/android/features/settings/price_alerts/presents/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/presents/PriceAlertTargetScene.kt @@ -36,17 +36,25 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import com.gemwallet.android.domains.price.PriceState import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.TabsBar import com.gemwallet.android.ui.components.buttons.MainActionButton import com.gemwallet.android.ui.components.clickable +import com.gemwallet.android.ui.components.image.AssetIcon +import com.gemwallet.android.ui.components.list_item.Badge +import com.gemwallet.android.ui.components.list_item.ListItem +import com.gemwallet.android.ui.components.list_item.ListItemTitleText +import com.gemwallet.android.ui.components.list_item.PriceInfo import com.gemwallet.android.ui.components.parseMarkdownToAnnotatedString import com.gemwallet.android.ui.components.screen.Scene +import com.gemwallet.android.ui.models.ListPosition import com.gemwallet.android.ui.theme.WalletTheme import com.gemwallet.android.ui.theme.paddingHalfSmall import com.gemwallet.android.ui.theme.paddingLarge import com.gemwallet.android.ui.theme.paddingSmall import com.gemwallet.android.features.settings.price_alerts.viewmodels.models.PriceAlertTargetError +import com.wallet.core.primitives.Asset import com.wallet.core.primitives.Currency import com.wallet.core.primitives.PriceAlertDirection import com.wallet.core.primitives.PriceAlertNotificationType @@ -65,6 +73,12 @@ fun PriceAlertTargetScene( currency: Currency, currentPriceValue: Double, currentPriceFormatted: String, + priceSuggestions: List> = emptyList(), + percentageSuggestions: List = listOf(5, 10, 15), + asset: Asset? = null, + assetPriceFormatted: String = "", + assetPriceChangeFormatted: String = "", + assetPriceState: PriceState = PriceState.None, error: PriceAlertTargetError?, onType: (PriceAlertNotificationType) -> Unit, onDirection: (PriceAlertDirection) -> Unit, @@ -102,11 +116,31 @@ fun PriceAlertTargetScene( } }, mainAction = { - MainActionButton( - title = stringResource(R.string.transfer_confirm), - enabled = error == null && value.text.isNotEmpty(), - onClick = onConfirm, - ) + if (value.text.isEmpty()) { + val suggestions = when (type) { + PriceAlertNotificationType.Price -> priceSuggestions + PriceAlertNotificationType.PricePercentChange -> percentageSuggestions.map { "$it%" to it.toString() } + else -> emptyList() + } + if (suggestions.isNotEmpty()) { + TabsBar( + tabs = suggestions, + selected = "" to "", + onSelect = { pair -> + value.edit { this.replace(0, this.length, pair.second) } + }, + equalWidth = false, + ) { pair -> + Text(pair.first) + } + } + } else { + MainActionButton( + title = stringResource(R.string.transfer_confirm), + enabled = error == null, + onClick = onConfirm, + ) + } }, onClose = onCancel, ) { @@ -146,25 +180,35 @@ fun PriceAlertTargetScene( horizontalArrangement = Arrangement.spacedBy(paddingHalfSmall), ) { Box(Modifier.weight(1f)) { - if (type == PriceAlertNotificationType.PricePercentChange) { - Icon( - modifier = Modifier.align(Alignment.CenterEnd).clickable { - val direction = when (direction) { - PriceAlertDirection.Up -> PriceAlertDirection.Down - PriceAlertDirection.Down -> PriceAlertDirection.Up - } - onDirection(direction) - }, - imageVector = when (direction) { - PriceAlertDirection.Up -> Icons.Default.ArrowCircleUp - PriceAlertDirection.Down -> Icons.Default.ArrowCircleDown - }, - contentDescription = "", - tint = when (direction) { - PriceAlertDirection.Up -> MaterialTheme.colorScheme.tertiary - PriceAlertDirection.Down -> MaterialTheme.colorScheme.error - }, - ) + when (type) { + PriceAlertNotificationType.Price -> { + Text( + modifier = Modifier.align(Alignment.CenterEnd), + text = java.util.Currency.getInstance(currency.string).symbol, + style = MaterialTheme.typography.displaySmall, + ) + } + PriceAlertNotificationType.PricePercentChange -> { + Icon( + modifier = Modifier.align(Alignment.CenterEnd).clickable { + val direction = when (direction) { + PriceAlertDirection.Up -> PriceAlertDirection.Down + PriceAlertDirection.Down -> PriceAlertDirection.Up + } + onDirection(direction) + }, + imageVector = when (direction) { + PriceAlertDirection.Up -> Icons.Default.ArrowCircleUp + PriceAlertDirection.Down -> Icons.Default.ArrowCircleDown + }, + contentDescription = "", + tint = when (direction) { + PriceAlertDirection.Up -> MaterialTheme.colorScheme.tertiary + PriceAlertDirection.Down -> MaterialTheme.colorScheme.error + }, + ) + } + else -> {} } } BasicTextField( @@ -185,30 +229,37 @@ fun PriceAlertTargetScene( } ) Box(Modifier.weight(1f)) { - Text( - text = when (type) { - PriceAlertNotificationType.Auto -> "" - PriceAlertNotificationType.Price -> java.util.Currency.getInstance(currency.string).symbol - PriceAlertNotificationType.PricePercentChange -> "%" - }, - style = MaterialTheme.typography.displaySmall, - ) + if (type == PriceAlertNotificationType.PricePercentChange) { + Text( + text = "%", + style = MaterialTheme.typography.displaySmall, + ) + } } } } item { Text( text = parseMarkdownToAnnotatedString("${stringResource(R.string.price_alerts_set_alert_current_price)} **$currentPriceFormatted**"), + color = MaterialTheme.colorScheme.secondary, style = MaterialTheme.typography.bodyLarge, ) } - - if (type == PriceAlertNotificationType.PricePercentChange) { - val percentage = listOf("5", "10", "15") + if (asset != null) { item { - TabsBar(tabs = percentage, selected = value.text, onSelect = { select -> value.edit { this.replace(0, this.length, select) } }) { - Text("$it%") - } + ListItem( + listPosition = ListPosition.Single, + leading = { AssetIcon(asset) }, + title = { ListItemTitleText(asset.name, titleBadge = { Badge(asset.symbol) }) }, + subtitle = { + PriceInfo( + price = assetPriceFormatted, + changes = assetPriceChangeFormatted, + state = assetPriceState, + style = MaterialTheme.typography.bodyMedium, + ) + }, + ) } } } @@ -224,8 +275,10 @@ fun PriceAlertTargetScenePricePreview() { direction = PriceAlertDirection.Up, type = PriceAlertNotificationType.Price, currency = Currency.USD, - currentPriceFormatted = "901.8$", + currentPriceFormatted = "$901.80", currentPriceValue = 901.8, + priceSuggestions = listOf("$850" to "850", "$950" to "950"), + percentageSuggestions = listOf(3, 6, 9), error = null, onType = {}, onDirection = {}, @@ -244,8 +297,10 @@ fun PriceAlertTargetScenePercentagePreview() { direction = PriceAlertDirection.Up, type = PriceAlertNotificationType.PricePercentChange, currency = Currency.USD, - currentPriceFormatted = "901.8$", + currentPriceFormatted = "$901.80", currentPriceValue = 901.8, + priceSuggestions = listOf("$850" to "850", "$950" to "950"), + percentageSuggestions = listOf(3, 6, 9), error = null, onType = {}, onDirection = {}, diff --git a/android/features/settings/price_alerts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/viewmodels/PriceAlertTargetViewModel.kt b/android/features/settings/price_alerts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/viewmodels/PriceAlertTargetViewModel.kt index 0ed91570a..85bbc6426 100644 --- a/android/features/settings/price_alerts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/viewmodels/PriceAlertTargetViewModel.kt +++ b/android/features/settings/price_alerts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/viewmodels/PriceAlertTargetViewModel.kt @@ -10,9 +10,14 @@ import com.gemwallet.android.data.repositories.assets.AssetsRepository import com.gemwallet.android.domains.pricealerts.direction import com.gemwallet.android.domains.pricealerts.formatAmount import com.gemwallet.android.ext.toAssetId +import com.gemwallet.android.domains.percentage.formatAsPercentage +import com.gemwallet.android.domains.price.PriceState +import com.gemwallet.android.domains.price.toPriceState +import com.gemwallet.android.model.compactFormatter import com.gemwallet.android.model.format import com.gemwallet.android.features.settings.price_alerts.viewmodels.models.PriceAlertConfirmResult import com.gemwallet.android.features.settings.price_alerts.viewmodels.models.PriceAlertTargetError +import com.wallet.core.primitives.Asset import com.wallet.core.primitives.Currency import com.wallet.core.primitives.PriceAlertDirection import com.wallet.core.primitives.PriceAlertNotificationType @@ -26,8 +31,12 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import uniffi.gemstone.priceAlertPercentageSuggestions +import uniffi.gemstone.priceAlertRoundedValues +import java.math.BigDecimal import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @@ -38,6 +47,8 @@ class PriceAlertTargetViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : ViewModel() { + private val suggestionOffsetPercent = 5.0 + val value = TextFieldState() val assetId = savedStateHandle.getStateFlow("assetId", null) @@ -55,6 +66,34 @@ class PriceAlertTargetViewModel @Inject constructor( val currentPriceValue = assetInfo.map { it?.price?.price?.price ?: 0.0 } .stateIn(viewModelScope, SharingStarted.Eagerly, 0.0) + val asset: StateFlow = assetInfo.map { it?.asset } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val priceFormatted: StateFlow = assetInfo.map { info -> + val priceInfo = info?.price ?: return@map "" + priceInfo.currency.compactFormatter(priceInfo.price.price) + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + val priceChangeFormatted: StateFlow = assetInfo.map { + it?.price?.price?.priceChangePercentage24h.formatAsPercentage() + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + val priceState: StateFlow = assetInfo.map { + it?.price?.price?.priceChangePercentage24h.toPriceState() + }.stateIn(viewModelScope, SharingStarted.Eagerly, PriceState.None) + + val priceSuggestions: StateFlow>> = combine(currentPriceValue, currency) { price, currency -> + if (price <= 0.0) return@combine emptyList() + priceAlertRoundedValues(price, suggestionOffsetPercent).map { value -> + val formatted = currency.format(BigDecimal.valueOf(value), dynamicPlace = true) + formatted to value.toBigDecimal().stripTrailingZeros().toPlainString() + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val percentageSuggestions: StateFlow> = currentPriceValue.map { price -> + priceAlertPercentageSuggestions(price).map { it.toInt() } + }.stateIn(viewModelScope, SharingStarted.Eagerly, listOf(5, 10, 15)) + val error: StateFlow = snapshotFlow { value.text }.map { val value = try { it.toString().toDouble() } catch (_: Throwable) { 0.0 } if (value <= 0.0) { diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatter.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatter.kt new file mode 100644 index 000000000..1abb87de2 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatter.kt @@ -0,0 +1,13 @@ +package com.gemwallet.android.model + +object PercentageSuggestionsFormatter { + + fun suggestions(price: Double): List { + val base = when { + price < 100 -> 5 + price < 10_000 -> 3 + else -> 2 + } + return listOf(base, base * 2, base * 3) + } +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatter.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatter.kt new file mode 100644 index 000000000..be07ff688 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatter.kt @@ -0,0 +1,32 @@ +package com.gemwallet.android.model + +import java.math.RoundingMode + +object PriceSuggestionsFormatter { + + fun roundedValues(price: Double, byPercent: Double = 5.0): List { + if (price < 0.01 || byPercent <= 0) return emptyList() + + val lowerTarget = price * (1 - byPercent / 100) + val upperTarget = price * (1 + byPercent / 100) + + val step = step(lowerTarget) + + val lower = Math.floor(lowerTarget / step) * step + val upper = ceil(upperTarget / step, if (step > 1) RoundingMode.HALF_UP else RoundingMode.UP) * step + + return listOf(lower, upper) + } + + private fun step(value: Double): Double = when { + value < 1 -> 0.01 + value < 100 -> 1.0 + value < 500 -> 10.0 + value < 10_000 -> 50.0 + else -> 1000.0 + } + + private fun ceil(value: Double, roundingMode: RoundingMode): Double { + return value.toBigDecimal().setScale(0, roundingMode).toDouble() + } +} diff --git a/android/gemcore/src/test/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatterTest.kt b/android/gemcore/src/test/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatterTest.kt new file mode 100644 index 000000000..2d058f254 --- /dev/null +++ b/android/gemcore/src/test/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatterTest.kt @@ -0,0 +1,37 @@ +package com.gemwallet.android.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PercentageSuggestionsFormatterTest { + + @Test + fun testLowPrice() { + assertEquals(listOf(5, 10, 15), PercentageSuggestionsFormatter.suggestions(50.0)) + } + + @Test + fun testMediumPrice() { + assertEquals(listOf(3, 6, 9), PercentageSuggestionsFormatter.suggestions(500.0)) + } + + @Test + fun testHighPrice() { + assertEquals(listOf(2, 4, 6), PercentageSuggestionsFormatter.suggestions(10_000.0)) + } + + @Test + fun testBoundaryAt100() { + assertEquals(listOf(3, 6, 9), PercentageSuggestionsFormatter.suggestions(100.0)) + } + + @Test + fun testBoundaryAt10000() { + assertEquals(listOf(2, 4, 6), PercentageSuggestionsFormatter.suggestions(10_000.0)) + } + + @Test + fun testVeryLowPrice() { + assertEquals(listOf(5, 10, 15), PercentageSuggestionsFormatter.suggestions(0.01)) + } +} diff --git a/android/gemcore/src/test/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatterTest.kt b/android/gemcore/src/test/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatterTest.kt new file mode 100644 index 000000000..0727ee2fd --- /dev/null +++ b/android/gemcore/src/test/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatterTest.kt @@ -0,0 +1,50 @@ +package com.gemwallet.android.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PriceSuggestionsFormatterTest { + + @Test + fun testHighPrice() { + val result = PriceSuggestionsFormatter.roundedValues(95_432.0) + assertEquals(2, result.size) + assertEquals(90_000.0, result[0], 0.01) + assertEquals(100_000.0, result[1], 0.01) + } + + @Test + fun testMediumPrice() { + val result = PriceSuggestionsFormatter.roundedValues(767.55) + assertEquals(2, result.size) + assertEquals(700.0, result[0], 0.01) + assertEquals(800.0, result[1], 0.01) + } + + @Test + fun testLowPrice() { + val result = PriceSuggestionsFormatter.roundedValues(0.2829) + assertEquals(2, result.size) + assertEquals(0.26, result[0], 0.01) + assertEquals(0.30, result[1], 0.01) + } + + @Test + fun testVeryLowPrice() { + val result = PriceSuggestionsFormatter.roundedValues(0.005) + assertTrue(result.isEmpty()) + } + + @Test + fun testZeroPrice() { + val result = PriceSuggestionsFormatter.roundedValues(0.0) + assertTrue(result.isEmpty()) + } + + @Test + fun testNegativePercent() { + val result = PriceSuggestionsFormatter.roundedValues(100.0, -5.0) + assertTrue(result.isEmpty()) + } +} From a7a696bc89b0839d85367c989fad8bebf5701ee1 Mon Sep 17 00:00:00 2001 From: Radmir Date: Tue, 14 Apr 2026 19:39:08 +0500 Subject: [PATCH 2/3] Move price alert suggestion logic to core Replace platform-specific PercentageSuggestionsFormatter and RoundingFormatter with shared Rust implementation exposed via Gemstone UniFFI bindings. Both iOS and Android now call priceAlertPercentageSuggestions and priceAlertRoundedValues directly. --- .../model/PercentageSuggestionsFormatter.kt | 13 ----- .../model/PriceSuggestionsFormatter.kt | 32 ------------ .../PercentageSuggestionsFormatterTest.kt | 37 -------------- .../model/PriceSuggestionsFormatterTest.kt | 50 ------------------- core | 2 +- ios/Features/PriceAlerts/Package.swift | 2 + .../ViewModels/SetPriceAlertViewModel.swift | 15 +++--- .../PercentageSuggestionsFormatter.swift | 20 -------- .../Sources/RoundingFormatter.swift | 30 ----------- .../PercentageSuggestionsFormatterTests.swift | 16 ------ .../RoundingFormatterTests.swift | 23 --------- 11 files changed, 10 insertions(+), 230 deletions(-) delete mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatter.kt delete mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatter.kt delete mode 100644 android/gemcore/src/test/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatterTest.kt delete mode 100644 android/gemcore/src/test/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatterTest.kt delete mode 100644 ios/Packages/Formatters/Sources/PercentageSuggestionsFormatter.swift delete mode 100644 ios/Packages/Formatters/Sources/RoundingFormatter.swift delete mode 100644 ios/Packages/Formatters/Tests/FormattersTests/PercentageSuggestionsFormatterTests.swift delete mode 100644 ios/Packages/Formatters/Tests/FormattersTests/RoundingFormatterTests.swift diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatter.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatter.kt deleted file mode 100644 index 1abb87de2..000000000 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatter.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.gemwallet.android.model - -object PercentageSuggestionsFormatter { - - fun suggestions(price: Double): List { - val base = when { - price < 100 -> 5 - price < 10_000 -> 3 - else -> 2 - } - return listOf(base, base * 2, base * 3) - } -} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatter.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatter.kt deleted file mode 100644 index be07ff688..000000000 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatter.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.gemwallet.android.model - -import java.math.RoundingMode - -object PriceSuggestionsFormatter { - - fun roundedValues(price: Double, byPercent: Double = 5.0): List { - if (price < 0.01 || byPercent <= 0) return emptyList() - - val lowerTarget = price * (1 - byPercent / 100) - val upperTarget = price * (1 + byPercent / 100) - - val step = step(lowerTarget) - - val lower = Math.floor(lowerTarget / step) * step - val upper = ceil(upperTarget / step, if (step > 1) RoundingMode.HALF_UP else RoundingMode.UP) * step - - return listOf(lower, upper) - } - - private fun step(value: Double): Double = when { - value < 1 -> 0.01 - value < 100 -> 1.0 - value < 500 -> 10.0 - value < 10_000 -> 50.0 - else -> 1000.0 - } - - private fun ceil(value: Double, roundingMode: RoundingMode): Double { - return value.toBigDecimal().setScale(0, roundingMode).toDouble() - } -} diff --git a/android/gemcore/src/test/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatterTest.kt b/android/gemcore/src/test/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatterTest.kt deleted file mode 100644 index 2d058f254..000000000 --- a/android/gemcore/src/test/kotlin/com/gemwallet/android/model/PercentageSuggestionsFormatterTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.gemwallet.android.model - -import org.junit.Assert.assertEquals -import org.junit.Test - -class PercentageSuggestionsFormatterTest { - - @Test - fun testLowPrice() { - assertEquals(listOf(5, 10, 15), PercentageSuggestionsFormatter.suggestions(50.0)) - } - - @Test - fun testMediumPrice() { - assertEquals(listOf(3, 6, 9), PercentageSuggestionsFormatter.suggestions(500.0)) - } - - @Test - fun testHighPrice() { - assertEquals(listOf(2, 4, 6), PercentageSuggestionsFormatter.suggestions(10_000.0)) - } - - @Test - fun testBoundaryAt100() { - assertEquals(listOf(3, 6, 9), PercentageSuggestionsFormatter.suggestions(100.0)) - } - - @Test - fun testBoundaryAt10000() { - assertEquals(listOf(2, 4, 6), PercentageSuggestionsFormatter.suggestions(10_000.0)) - } - - @Test - fun testVeryLowPrice() { - assertEquals(listOf(5, 10, 15), PercentageSuggestionsFormatter.suggestions(0.01)) - } -} diff --git a/android/gemcore/src/test/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatterTest.kt b/android/gemcore/src/test/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatterTest.kt deleted file mode 100644 index 0727ee2fd..000000000 --- a/android/gemcore/src/test/kotlin/com/gemwallet/android/model/PriceSuggestionsFormatterTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.gemwallet.android.model - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class PriceSuggestionsFormatterTest { - - @Test - fun testHighPrice() { - val result = PriceSuggestionsFormatter.roundedValues(95_432.0) - assertEquals(2, result.size) - assertEquals(90_000.0, result[0], 0.01) - assertEquals(100_000.0, result[1], 0.01) - } - - @Test - fun testMediumPrice() { - val result = PriceSuggestionsFormatter.roundedValues(767.55) - assertEquals(2, result.size) - assertEquals(700.0, result[0], 0.01) - assertEquals(800.0, result[1], 0.01) - } - - @Test - fun testLowPrice() { - val result = PriceSuggestionsFormatter.roundedValues(0.2829) - assertEquals(2, result.size) - assertEquals(0.26, result[0], 0.01) - assertEquals(0.30, result[1], 0.01) - } - - @Test - fun testVeryLowPrice() { - val result = PriceSuggestionsFormatter.roundedValues(0.005) - assertTrue(result.isEmpty()) - } - - @Test - fun testZeroPrice() { - val result = PriceSuggestionsFormatter.roundedValues(0.0) - assertTrue(result.isEmpty()) - } - - @Test - fun testNegativePercent() { - val result = PriceSuggestionsFormatter.roundedValues(100.0, -5.0) - assertTrue(result.isEmpty()) - } -} diff --git a/core b/core index 3b5a66e74..4ed754b28 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 3b5a66e74a21bd7529c9cc25a629b2b7357bc546 +Subproject commit 4ed754b2891effcde7ec2a7eababc5702335c4e0 diff --git a/ios/Features/PriceAlerts/Package.swift b/ios/Features/PriceAlerts/Package.swift index 44326938d..aa3bac715 100644 --- a/ios/Features/PriceAlerts/Package.swift +++ b/ios/Features/PriceAlerts/Package.swift @@ -20,6 +20,7 @@ let package = Package( .package(name: "Style", path: "../../Packages/Style"), .package(name: "Localization", path: "../../Packages/Localization"), .package(name: "PrimitivesComponents", path: "../../Packages/PrimitivesComponents"), + .package(name: "Gemstone", path: "../../Packages/Gemstone"), .package(name: "Store", path: "../../Packages/Store"), .package(name: "Preferences", path: "../../Packages/Preferences"), @@ -34,6 +35,7 @@ let package = Package( "Style", "Localization", "PrimitivesComponents", + "Gemstone", "Store", .product(name: "PriceAlertService", package: "FeatureServices"), "Preferences", diff --git a/ios/Features/PriceAlerts/Sources/ViewModels/SetPriceAlertViewModel.swift b/ios/Features/PriceAlerts/Sources/ViewModels/SetPriceAlertViewModel.swift index 4f634f5e9..d1df86ba4 100644 --- a/ios/Features/PriceAlerts/Sources/ViewModels/SetPriceAlertViewModel.swift +++ b/ios/Features/PriceAlerts/Sources/ViewModels/SetPriceAlertViewModel.swift @@ -3,6 +3,7 @@ import Components import Formatters import Foundation +import Gemstone import Localization import Preferences import PriceAlertService @@ -19,9 +20,7 @@ public final class SetPriceAlertViewModel { private let onComplete: StringAction private let preferences = Preferences.standard private let currencyFormatter = CurrencyFormatter(currencyCode: Preferences.standard.currency) - private let roundingFormatter = RoundingFormatter() - private let percentageSuggestionsFormatter = PercentageSuggestionsFormatter() - private let priceSuggestionPercent: Double = 5 + private let suggestionOffsetPercent: Double = 5 var state: SetPriceAlertViewModelState @@ -44,17 +43,17 @@ public final class SetPriceAlertViewModel { func percentageSuggestions(for price: Price?) -> [PercentageSuggestion] { guard let currentPrice = price?.price else { return [] } - return percentageSuggestionsFormatter.suggestions(for: currentPrice).map { - PercentageSuggestion(value: $0) + return Gemstone.priceAlertPercentageSuggestions(price: currentPrice).map { + PercentageSuggestion(value: $0.asInt) } } func priceSuggestions(for price: Price?) -> [PriceSuggestion] { guard let currentPrice = price?.price else { return [] } - return roundingFormatter.roundedValues(for: currentPrice, byPercent: priceSuggestionPercent).map { value in + return Gemstone.priceAlertRoundedValues(price: currentPrice, byPercent: suggestionOffsetPercent).map { PriceSuggestion( - title: currencyFormatter.string(value), - value: value, + title: currencyFormatter.string($0), + value: $0, ) } } diff --git a/ios/Packages/Formatters/Sources/PercentageSuggestionsFormatter.swift b/ios/Packages/Formatters/Sources/PercentageSuggestionsFormatter.swift deleted file mode 100644 index a027fc5ba..000000000 --- a/ios/Packages/Formatters/Sources/PercentageSuggestionsFormatter.swift +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation - -public struct PercentageSuggestionsFormatter: Sendable { - public init() {} - - public func suggestions(for price: Double) -> [Int] { - let base = base(for: price) - return [base, base * 2, base * 3] - } - - private func base(for price: Double) -> Int { - switch price { - case ..<100: 5 - case ..<10000: 3 - default: 2 - } - } -} diff --git a/ios/Packages/Formatters/Sources/RoundingFormatter.swift b/ios/Packages/Formatters/Sources/RoundingFormatter.swift deleted file mode 100644 index 33d4f7757..000000000 --- a/ios/Packages/Formatters/Sources/RoundingFormatter.swift +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation - -public struct RoundingFormatter: Sendable { - public init() {} - - public func roundedValues(for value: T, byPercent: T) -> [T] { - guard value >= 0.01, byPercent > 0 else { return [] } - - let lowerTarget = value * (1 - byPercent / 100) - let upperTarget = value * (1 + byPercent / 100) - let step = step(for: lowerTarget) - let upperRounding: FloatingPointRoundingRule = step > 1 ? .toNearestOrAwayFromZero : .up - let lower = (lowerTarget / step).rounded(.down) * step - let upper = (upperTarget / step).rounded(upperRounding) * step - - return [lower, upper] - } - - private func step(for value: T) -> T { - switch value { - case ..<1: 0.01 - case ..<100: 1 - case ..<500: 10 - case ..<10000: 50 - default: 1000 - } - } -} diff --git a/ios/Packages/Formatters/Tests/FormattersTests/PercentageSuggestionsFormatterTests.swift b/ios/Packages/Formatters/Tests/FormattersTests/PercentageSuggestionsFormatterTests.swift deleted file mode 100644 index 0fc75580a..000000000 --- a/ios/Packages/Formatters/Tests/FormattersTests/PercentageSuggestionsFormatterTests.swift +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Formatters -import Testing - -struct PercentageSuggestionsFormatterTests { - let formatter = PercentageSuggestionsFormatter() - - @Test - func suggestions() { - #expect(formatter.suggestions(for: 0.28) == [5, 10, 15]) - #expect(formatter.suggestions(for: 3.90) == [5, 10, 15]) - #expect(formatter.suggestions(for: 767.0) == [3, 6, 9]) - #expect(formatter.suggestions(for: 78151.0) == [2, 4, 6]) - } -} diff --git a/ios/Packages/Formatters/Tests/FormattersTests/RoundingFormatterTests.swift b/ios/Packages/Formatters/Tests/FormattersTests/RoundingFormatterTests.swift deleted file mode 100644 index ef20f0687..000000000 --- a/ios/Packages/Formatters/Tests/FormattersTests/RoundingFormatterTests.swift +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Formatters -import Testing - -struct RoundingFormatterTests { - let formatter = RoundingFormatter() - - @Test - func roundedValues() { - #expect(formatter.roundedValues(for: 0.0, byPercent: 5) == []) - #expect(formatter.roundedValues(for: 0.009, byPercent: 5) == []) - #expect(formatter.roundedValues(for: 0.2829, byPercent: 5) == [0.26, 0.3]) - #expect(formatter.roundedValues(for: 3.90, byPercent: 5) == [3, 5]) - #expect(formatter.roundedValues(for: 10.05, byPercent: 5) == [9, 11]) - #expect(formatter.roundedValues(for: 103.0, byPercent: 5) == [97, 109]) - #expect(formatter.roundedValues(for: 767.55, byPercent: 5) == [700, 800]) - #expect(formatter.roundedValues(for: 2283.0, byPercent: 5) == [2150, 2400]) - #expect(formatter.roundedValues(for: 95432.0, byPercent: 5) == [90000, 100_000]) - #expect(formatter.roundedValues(for: 110_000.0, byPercent: 5) == [104_000, 116_000]) - #expect(formatter.roundedValues(for: 149_000.0, byPercent: 5) == [141_000, 156_000]) - } -} From a06d462fbac1cdc4e79d81ec1aa4538c5dd90323 Mon Sep 17 00:00:00 2001 From: Radmir Date: Tue, 14 Apr 2026 21:00:07 +0500 Subject: [PATCH 3/3] Wrap price alert functions in PriceAlertFormatter struct --- .../price_alerts/viewmodels/PriceAlertTargetViewModel.kt | 8 ++++---- core | 2 +- .../Sources/ViewModels/SetPriceAlertViewModel.swift | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/android/features/settings/price_alerts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/viewmodels/PriceAlertTargetViewModel.kt b/android/features/settings/price_alerts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/viewmodels/PriceAlertTargetViewModel.kt index 85bbc6426..18aa91d52 100644 --- a/android/features/settings/price_alerts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/viewmodels/PriceAlertTargetViewModel.kt +++ b/android/features/settings/price_alerts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/price_alerts/viewmodels/PriceAlertTargetViewModel.kt @@ -34,8 +34,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import uniffi.gemstone.priceAlertPercentageSuggestions -import uniffi.gemstone.priceAlertRoundedValues +import uniffi.gemstone.PriceAlertFormatter import java.math.BigDecimal import javax.inject.Inject @@ -47,6 +46,7 @@ class PriceAlertTargetViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : ViewModel() { + private val priceAlertFormatter = PriceAlertFormatter() private val suggestionOffsetPercent = 5.0 val value = TextFieldState() @@ -84,14 +84,14 @@ class PriceAlertTargetViewModel @Inject constructor( val priceSuggestions: StateFlow>> = combine(currentPriceValue, currency) { price, currency -> if (price <= 0.0) return@combine emptyList() - priceAlertRoundedValues(price, suggestionOffsetPercent).map { value -> + priceAlertFormatter.roundedValues(price, suggestionOffsetPercent).map { value -> val formatted = currency.format(BigDecimal.valueOf(value), dynamicPlace = true) formatted to value.toBigDecimal().stripTrailingZeros().toPlainString() } }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) val percentageSuggestions: StateFlow> = currentPriceValue.map { price -> - priceAlertPercentageSuggestions(price).map { it.toInt() } + priceAlertFormatter.percentageSuggestions(price).map { it.toInt() } }.stateIn(viewModelScope, SharingStarted.Eagerly, listOf(5, 10, 15)) val error: StateFlow = snapshotFlow { value.text }.map { diff --git a/core b/core index 4ed754b28..a806f26b0 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 4ed754b2891effcde7ec2a7eababc5702335c4e0 +Subproject commit a806f26b0375c67356398bf182e4dedf0d0ecd9b diff --git a/ios/Features/PriceAlerts/Sources/ViewModels/SetPriceAlertViewModel.swift b/ios/Features/PriceAlerts/Sources/ViewModels/SetPriceAlertViewModel.swift index d1df86ba4..cb72a0989 100644 --- a/ios/Features/PriceAlerts/Sources/ViewModels/SetPriceAlertViewModel.swift +++ b/ios/Features/PriceAlerts/Sources/ViewModels/SetPriceAlertViewModel.swift @@ -20,6 +20,7 @@ public final class SetPriceAlertViewModel { private let onComplete: StringAction private let preferences = Preferences.standard private let currencyFormatter = CurrencyFormatter(currencyCode: Preferences.standard.currency) + private let priceAlertFormatter = PriceAlertFormatter() private let suggestionOffsetPercent: Double = 5 var state: SetPriceAlertViewModelState @@ -43,14 +44,14 @@ public final class SetPriceAlertViewModel { func percentageSuggestions(for price: Price?) -> [PercentageSuggestion] { guard let currentPrice = price?.price else { return [] } - return Gemstone.priceAlertPercentageSuggestions(price: currentPrice).map { + return priceAlertFormatter.percentageSuggestions(price: currentPrice).map { PercentageSuggestion(value: $0.asInt) } } func priceSuggestions(for price: Price?) -> [PriceSuggestion] { guard let currentPrice = price?.price else { return [] } - return Gemstone.priceAlertRoundedValues(price: currentPrice, byPercent: suggestionOffsetPercent).map { + return priceAlertFormatter.roundedValues(price: currentPrice, byPercent: suggestionOffsetPercent).map { PriceSuggestion( title: currencyFormatter.string($0), value: $0,