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 707cfd26b..1bc44fd0d 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 @@ -24,6 +24,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, @@ -32,6 +38,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..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 @@ -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,11 @@ 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.PriceAlertFormatter +import java.math.BigDecimal import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @@ -38,6 +46,9 @@ class PriceAlertTargetViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : ViewModel() { + private val priceAlertFormatter = PriceAlertFormatter() + 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() + 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 -> + priceAlertFormatter.percentageSuggestions(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/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..cb72a0989 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,8 @@ 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 priceAlertFormatter = PriceAlertFormatter() + private let suggestionOffsetPercent: Double = 5 var state: SetPriceAlertViewModelState @@ -44,17 +44,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 priceAlertFormatter.percentageSuggestions(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 priceAlertFormatter.roundedValues(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]) - } -}