diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RouteArgumentsNavEntryDecorator.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RouteArgumentsNavEntryDecorator.kt index 37847237dd..e6f7ccfdc8 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RouteArgumentsNavEntryDecorator.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RouteArgumentsNavEntryDecorator.kt @@ -3,18 +3,11 @@ package com.gemwallet.android.ui.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember -import androidx.lifecycle.DEFAULT_ARGS_KEY -import androidx.lifecycle.HasDefaultViewModelProviderFactory -import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY -import androidx.lifecycle.SavedStateViewModelFactory -import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.enableSavedStateHandles import androidx.lifecycle.viewmodel.CreationExtras -import androidx.lifecycle.viewmodel.MutableCreationExtras import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntryDecorator @@ -23,12 +16,12 @@ import androidx.navigation3.runtime.NavMetadataKey import androidx.navigation3.runtime.get import androidx.navigation3.runtime.metadata import androidx.savedstate.SavedState -import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.compose.LocalSavedStateRegistryOwner import androidx.savedstate.savedState import com.gemwallet.android.ext.toIdentifier import com.gemwallet.android.ui.models.navigation.RouteArgument +import com.gemwallet.android.ui.viewmodel.NavEntryViewModelStoreOwner import com.wallet.core.primitives.AssetId import kotlin.reflect.KClass @@ -110,7 +103,7 @@ private fun rememberEntryViewModelStoreOwner( entryViewModelStores.store(contentKey) } return remember(parent, store, defaultArgs, savedStateRegistryOwner) { - RouteArgumentsViewModelStoreOwner( + NavEntryViewModelStoreOwner( parent = parent, store = store, savedStateRegistryOwner = checkNotNull(savedStateRegistryOwner) { @@ -158,43 +151,6 @@ private object EntryViewModelStoresFactory : ViewModelProvider.Factory { } } -private class RouteArgumentsViewModelStoreOwner( - private val parent: ViewModelStoreOwner, - private val store: ViewModelStore, - private val savedStateRegistryOwner: SavedStateRegistryOwner, - private val defaultArgs: SavedState, -) : ViewModelStoreOwner, - SavedStateRegistryOwner, - HasDefaultViewModelProviderFactory { - - init { - enableSavedStateHandles() - } - - override val viewModelStore: ViewModelStore - get() = store - - override val savedStateRegistry: SavedStateRegistry - get() = savedStateRegistryOwner.savedStateRegistry - - override val lifecycle - get() = savedStateRegistryOwner.lifecycle - - override val defaultViewModelProviderFactory: ViewModelProvider.Factory - get() = (parent as? HasDefaultViewModelProviderFactory)?.defaultViewModelProviderFactory - ?: SavedStateViewModelFactory() - - override val defaultViewModelCreationExtras: CreationExtras - get() = MutableCreationExtras( - (parent as? HasDefaultViewModelProviderFactory)?.defaultViewModelCreationExtras - ?: CreationExtras.Empty - ).apply { - this[SAVED_STATE_REGISTRY_OWNER_KEY] = this@RouteArgumentsViewModelStoreOwner - this[VIEW_MODEL_STORE_OWNER_KEY] = this@RouteArgumentsViewModelStoreOwner - this[DEFAULT_ARGS_KEY] = defaultArgs - } -} - internal fun NavEntry.withOccurrenceContentKey( key: NavKey, occurrence: Int, @@ -209,3 +165,4 @@ internal fun NavEntry.withOccurrenceContentKey( entry.Content() } } + diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Perpetual.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Perpetual.kt index f69f684d55..2ef61b478c 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Perpetual.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Perpetual.kt @@ -5,6 +5,7 @@ import androidx.navigation3.runtime.NavKey import com.gemwallet.android.features.perpetual.views.market.PerpetualMarketNavScreen import com.gemwallet.android.features.perpetual.views.position.PerpetualPositionNavScreen import com.gemwallet.android.ui.models.actions.AmountTransactionAction +import com.gemwallet.android.ui.models.actions.AssetIdAction import com.gemwallet.android.ui.models.actions.ConfirmTransactionAction import com.gemwallet.android.ui.navigation.assetIdArgument import com.gemwallet.android.ui.navigation.routeArguments @@ -20,7 +21,7 @@ data class PerpetualPositionRoute(val assetId: AssetId) : NavKey fun EntryProviderScope.perpetualScreen( onCancel: () -> Unit, - onOpenPerpetualDetails: (AssetId) -> Unit, + onOpenPerpetualDetails: AssetIdAction, amountAction: AmountTransactionAction, confirmAction: ConfirmTransactionAction, onTransaction: (TransactionId) -> Unit, @@ -28,7 +29,7 @@ fun EntryProviderScope.perpetualScreen( entry { PerpetualMarketNavScreen( onOpenPerpetualDetails = onOpenPerpetualDetails, - onCancel = onCancel + onCancel = onCancel, ) } diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/perpetuals/BuildPerpetualParamsImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/perpetuals/BuildPerpetualParamsImpl.kt index 94554ff7c0..2ebd85c394 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/perpetuals/BuildPerpetualParamsImpl.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/perpetuals/BuildPerpetualParamsImpl.kt @@ -15,6 +15,8 @@ import com.wallet.core.primitives.PerpetualData import com.wallet.core.primitives.PerpetualDirection import com.wallet.core.primitives.PerpetualId import com.wallet.core.primitives.PerpetualMarginType +import com.wallet.core.primitives.PerpetualModifyConfirmData +import com.wallet.core.primitives.PerpetualModifyPositionType import com.wallet.core.primitives.PerpetualPosition import com.wallet.core.primitives.PerpetualType import kotlinx.coroutines.flow.firstOrNull @@ -63,6 +65,27 @@ class BuildPerpetualParamsImpl( .perpetual(PerpetualType.Close(confirmData)) } + override suspend fun modify( + perpetualId: PerpetualId, + modifyTypes: List, + takeProfitOrderId: ULong?, + stopLossOrderId: ULong?, + ): ConfirmParams.PerpetualParams? { + if (modifyTypes.isEmpty()) return null + val data = getPerpetual(perpetualId) ?: return null + val assetIndex = data.perpetual.identifier.toIntOrNull() ?: return null + val account = sessionRepository.session().value?.wallet?.hyperliquidAccount ?: return null + val confirmData = PerpetualModifyConfirmData( + baseAsset = HypercoreUSDC, + assetIndex = assetIndex, + modifyTypes = modifyTypes, + takeProfitOrderId = takeProfitOrderId?.toLong(), + stopLossOrderId = stopLossOrderId?.toLong(), + ) + return ConfirmParams.Builder(data.asset, account) + .perpetual(PerpetualType.Modify(confirmData)) + } + private suspend fun getPerpetual(perpetualId: PerpetualId): PerpetualData? = perpetualRepository.getPerpetual(perpetualId).firstOrNull() diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/config/UserConfig.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/config/UserConfig.kt index a52452bc34..fa71f690de 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/config/UserConfig.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/config/UserConfig.kt @@ -81,6 +81,24 @@ class UserConfig( } } + fun perpetualTakeProfit(): Flow = context.dataStore.data + .map { preferences -> preferences[Key.PerpetualTakeProfit] ?: PerpetualConfig.defaultTakeProfit } + + suspend fun setPerpetualTakeProfit(value: Int) { + context.dataStore.edit { preferences -> + preferences[Key.PerpetualTakeProfit] = value + } + } + + fun perpetualStopLoss(): Flow = context.dataStore.data + .map { preferences -> preferences[Key.PerpetualStopLoss] ?: PerpetualConfig.defaultStopLoss } + + suspend fun setPerpetualStopLoss(value: Int) { + context.dataStore.edit { preferences -> + preferences[Key.PerpetualStopLoss] = value + } + } + fun getLatestAppUpdate(): Flow = context.dataStore.data .map { preferences -> val version = preferences[Key.LatestVersion].orEmpty() @@ -206,6 +224,8 @@ class UserConfig( val AskNotifications = longPreferencesKey("ask_notifications") val IsPerpetualEnabled = booleanPreferencesKey("is_perpetual_enabled") val PerpetualLeverage = intPreferencesKey("perpetual_leverage") + val PerpetualTakeProfit = intPreferencesKey("perpetual_take_profit") + val PerpetualStopLoss = intPreferencesKey("perpetual_stop_loss") } } diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/stream/StreamObserverService.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/stream/StreamObserverService.kt index b10cdfd9c7..799b763ecc 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/stream/StreamObserverService.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/stream/StreamObserverService.kt @@ -8,6 +8,7 @@ import com.gemwallet.android.application.perpetual.coordinators.SyncPerpetuals import com.gemwallet.android.data.repositories.config.UserConfig import com.gemwallet.android.data.repositories.session.SessionRepository import com.gemwallet.android.ext.hasPerpetualsSupport +import com.gemwallet.android.model.Session import com.gemwallet.android.data.services.gemapi.http.DeviceRequestSigner import com.gemwallet.android.serializer.StreamEventSerializer import com.gemwallet.android.serializer.jsonEncoder @@ -28,8 +29,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString @@ -64,12 +67,14 @@ class StreamObserverService( subscriptionService.setupAssets(wallet.id) if (connectionJob == null) start() runCatching { syncAssets() } - if (wallet.hasPerpetualsSupport && userConfig.isPerpetualEnabled().first()) { - runCatching { syncPerpetuals.syncPerpetuals() } - runCatching { syncPerpetualPositions.syncPerpetualPositions() } - } } } + scope.launchPerpetualSync( + session = sessionRepository.session(), + isPerpetualEnabled = userConfig.isPerpetualEnabled(), + syncPerpetuals = syncPerpetuals, + syncPerpetualPositions = syncPerpetualPositions, + ) } fun start() { @@ -139,3 +144,21 @@ class StreamObserverService( private const val PING_INTERVAL_MS = 30_000L } } + +internal fun CoroutineScope.launchPerpetualSync( + session: Flow, + isPerpetualEnabled: Flow, + syncPerpetuals: SyncPerpetuals, + syncPerpetualPositions: SyncPerpetualPositions, +): Job = launch { + combine(session, isPerpetualEnabled) { current, enabled -> + val wallet = current?.wallet + if (wallet != null && wallet.hasPerpetualsSupport && enabled) wallet.id.id else null + } + .distinctUntilChanged() + .collectLatest { walletId -> + if (walletId == null) return@collectLatest + runCatching { syncPerpetuals.syncPerpetuals() } + runCatching { syncPerpetualPositions.syncPerpetualPositions() } + } +} diff --git a/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt b/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt index a321f09ca5..df66fdcfc7 100644 --- a/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt +++ b/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt @@ -39,6 +39,7 @@ import com.gemwallet.android.model.AuthRequest import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.ValueFormatter import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.perpetual.AutocloseSummaryRow import com.gemwallet.android.ui.components.perpetual.PerpetualDetailsBottomSheet import com.gemwallet.android.ui.components.perpetual.PerpetualDetailsSummaryItem import com.gemwallet.android.ui.components.perpetual.title @@ -317,6 +318,11 @@ private fun ConfirmDetailElementRow( onClick = onClick, listPosition = listPosition, ) + is ConfirmDetailElement.PerpetualModifyAutoclose -> AutocloseSummaryRow( + takeProfitText = item.takeProfitText, + stopLossText = item.stopLossText, + listPosition = listPosition, + ) } } @@ -340,6 +346,8 @@ private fun ConfirmDetailElementBottomSheet( onDismiss = onDismiss, ) + is ConfirmDetailElement.PerpetualModifyAutoclose -> Unit + null -> Unit } } diff --git a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/ConfirmDetailElement.kt b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/ConfirmDetailElement.kt index d1ae3b4dfd..b6ec5352c3 100644 --- a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/ConfirmDetailElement.kt +++ b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/ConfirmDetailElement.kt @@ -11,4 +11,9 @@ sealed interface ConfirmDetailElement { data class PerpetualDetails( val model: PerpetualConfirmDetailsUIModel, ) : ConfirmDetailElement + + data class PerpetualModifyAutoclose( + val takeProfitText: String?, + val stopLossText: String?, + ) : ConfirmDetailElement } diff --git a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/PerpetualModifyAutocloseFactory.kt b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/PerpetualModifyAutocloseFactory.kt new file mode 100644 index 0000000000..9214221fae --- /dev/null +++ b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/models/PerpetualModifyAutocloseFactory.kt @@ -0,0 +1,39 @@ +package com.gemwallet.android.features.confirm.models + +import com.gemwallet.android.model.CurrencyFormatter +import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PerpetualModifyConfirmData +import com.wallet.core.primitives.PerpetualModifyPositionType + +object PerpetualModifyAutocloseFactory { + + private const val ClearedPlaceholder: String = "-" + + fun create(data: PerpetualModifyConfirmData): ConfirmDetailElement.PerpetualModifyAutoclose? { + val tpsl = data.modifyTypes + .filterIsInstance() + .firstOrNull()?.content + val cancelOrderIds = data.modifyTypes + .filterIsInstance() + .flatMap { it.content } + .map { it.orderId } + .toSet() + val takeProfitCanceled = data.takeProfitOrderId != null && + data.takeProfitOrderId in cancelOrderIds + val stopLossCanceled = data.stopLossOrderId != null && + data.stopLossOrderId in cancelOrderIds + val formatter = CurrencyFormatter(currency = Currency.USD) + val takeProfitText: String? = when { + tpsl?.takeProfit != null -> tpsl.takeProfit?.toDoubleOrNull()?.let(formatter::string) + takeProfitCanceled -> ClearedPlaceholder + else -> null + } + val stopLossText: String? = when { + tpsl?.stopLoss != null -> tpsl.stopLoss?.toDoubleOrNull()?.let(formatter::string) + stopLossCanceled -> ClearedPlaceholder + else -> null + } + if (takeProfitText == null && stopLossText == null) return null + return ConfirmDetailElement.PerpetualModifyAutoclose(takeProfitText, stopLossText) + } +} diff --git a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmViewModel.kt b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmViewModel.kt index d684199d0e..e1e3126e3a 100644 --- a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmViewModel.kt +++ b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmViewModel.kt @@ -27,11 +27,13 @@ import com.gemwallet.android.ui.models.swap.SwapProviderUIModelFactory import com.gemwallet.android.ui.models.actions.FinishConfirmAction import com.gemwallet.android.domains.confirm.AmountUIModel import com.gemwallet.android.features.confirm.models.ConfirmDetailElement +import com.gemwallet.android.features.confirm.models.PerpetualModifyAutocloseFactory import com.gemwallet.android.domains.confirm.ConfirmError import com.gemwallet.android.domains.confirm.ConfirmState import com.gemwallet.android.domains.confirm.FeeUIModel import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PerpetualType import com.wallet.core.primitives.FeePriority import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -323,9 +325,10 @@ class ConfirmViewModel @Inject constructor( private fun buildPerpetualDetailElement( params: ConfirmParams.PerpetualParams?, - ): ConfirmDetailElement.PerpetualDetails? { - val model = PerpetualConfirmDetailsUIModelFactory.create(params?.perpetualType ?: return null) ?: return null - return ConfirmDetailElement.PerpetualDetails(model) + ): ConfirmDetailElement? = when (val type = params?.perpetualType) { + null -> null + is PerpetualType.Modify -> PerpetualModifyAutocloseFactory.create(type.content) + else -> PerpetualConfirmDetailsUIModelFactory.create(type)?.let(ConfirmDetailElement::PerpetualDetails) } private fun buildSwapDetailElement( diff --git a/android/features/confirm/viewmodels/src/test/kotlin/com/gemwallet/android/features/confirm/models/PerpetualModifyAutocloseFactoryTest.kt b/android/features/confirm/viewmodels/src/test/kotlin/com/gemwallet/android/features/confirm/models/PerpetualModifyAutocloseFactoryTest.kt new file mode 100644 index 0000000000..096c21e907 --- /dev/null +++ b/android/features/confirm/viewmodels/src/test/kotlin/com/gemwallet/android/features/confirm/models/PerpetualModifyAutocloseFactoryTest.kt @@ -0,0 +1,48 @@ +package com.gemwallet.android.features.confirm.models + +import com.gemwallet.android.testkit.mockCancel +import com.gemwallet.android.testkit.mockPerpetualModifyConfirmData +import com.gemwallet.android.testkit.mockTpslOrder +import org.junit.Assert.assertEquals +import org.junit.Test + +class PerpetualModifyAutocloseFactoryTest { + + @Test + fun setBothPrices() { + val element = PerpetualModifyAutocloseFactory.create( + mockPerpetualModifyConfirmData( + modifyTypes = listOf(mockTpslOrder(takeProfit = "65000", stopLoss = "55000")), + ), + ) + assertEquals("$65,000.00", element?.takeProfitText) + assertEquals("$55,000.00", element?.stopLossText) + } + + @Test + fun cancelExistingShowsDash() { + val element = PerpetualModifyAutocloseFactory.create( + mockPerpetualModifyConfirmData( + modifyTypes = listOf(mockCancel(orderIds = listOf(111L, 222L))), + takeProfitOrderId = 111L, + stopLossOrderId = 222L, + ), + ) + assertEquals("-", element?.takeProfitText) + assertEquals("-", element?.stopLossText) + } + + @Test + fun cancelAndSetShowsNewPriceNotDash() { + val element = PerpetualModifyAutocloseFactory.create( + mockPerpetualModifyConfirmData( + modifyTypes = listOf( + mockCancel(orderIds = listOf(111L)), + mockTpslOrder(takeProfit = "70000"), + ), + takeProfitOrderId = 111L, + ), + ) + assertEquals("$70,000.00", element?.takeProfitText) + } +} diff --git a/android/features/perpetual/presents/build.gradle.kts b/android/features/perpetual/presents/build.gradle.kts index 15f1198bb6..9b3aa8e9dd 100644 --- a/android/features/perpetual/presents/build.gradle.kts +++ b/android/features/perpetual/presents/build.gradle.kts @@ -54,11 +54,15 @@ android { dependencies { implementation(project(":ui")) implementation(project(":features:perpetual:viewmodels")) + implementation(project(":features:confirm:presents")) implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.hilt.lifecycle.viewmodel.compose) + implementation(libs.navigation3.runtime) + implementation(libs.navigation3.ui) + debugImplementation(libs.androidx.ui.tooling) implementation(libs.androidx.ui.tooling.preview) diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseAction.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseAction.kt new file mode 100644 index 0000000000..7d2132fcaf --- /dev/null +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseAction.kt @@ -0,0 +1,11 @@ +package com.gemwallet.android.features.perpetual.views.autoclose + +import com.wallet.core.primitives.TpslType + +internal sealed interface AutocloseAction { + data object Close : AutocloseAction + data object Confirm : AutocloseAction + data class TakeProfitChanged(val text: String) : AutocloseAction + data class StopLossChanged(val text: String) : AutocloseAction + data class SelectPercent(val type: TpslType, val percent: Int) : AutocloseAction +} diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseNavGraph.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseNavGraph.kt new file mode 100644 index 0000000000..0080ce4d9a --- /dev/null +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseNavGraph.kt @@ -0,0 +1,219 @@ +package com.gemwallet.android.features.perpetual.views.autoclose + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.scene.Scene +import androidx.navigation3.ui.NavDisplay +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.savedState +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gemwallet.android.features.confirm.presents.ConfirmScreen +import com.gemwallet.android.features.perpetual.viewmodels.AutocloseViewModel +import com.gemwallet.android.model.ConfirmParams +import com.gemwallet.android.ui.components.animation.navigationSlideTransition +import com.gemwallet.android.ui.models.actions.FinishConfirmAction +import com.gemwallet.android.ui.viewmodel.NavEntryViewModelStoreOwner +import kotlinx.serialization.Serializable + +@Composable +fun AutocloseNavGraph( + onDismiss: () -> Unit, + finishAction: FinishConfirmAction, +) { + val rootOwner = rememberAutocloseRootViewModelStoreOwner() + CompositionLocalProvider(LocalViewModelStoreOwner provides rootOwner) { + AutocloseNavGraphContent( + onDismiss = onDismiss, + finishAction = finishAction, + ) + } +} + +@Composable +private fun AutocloseNavGraphContent( + onDismiss: () -> Unit, + finishAction: FinishConfirmAction, +) { + val viewModel: AutocloseViewModel = hiltViewModel() + val uiModel by viewModel.uiModel.collectAsStateWithLifecycle() + val takeProfitText by viewModel.takeProfitText.collectAsStateWithLifecycle() + val stopLossText by viewModel.stopLossText.collectAsStateWithLifecycle() + + val backStack = remember { mutableStateListOf(AutocloseRoute) } + var confirmParams by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.confirmRequests.collect { params -> + confirmParams = params + if (backStack.lastOrNull() != AutocloseConfirmRoute) { + backStack.add(AutocloseConfirmRoute) + } + } + } + + val popInternal = { + if (backStack.size > 1) backStack.removeAt(backStack.lastIndex) + } + + val entryProvider = entryProvider { + entry { + val model = uiModel ?: return@entry + AutocloseScene( + model = model, + takeProfitText = takeProfitText, + stopLossText = stopLossText, + onAction = { action -> + when (action) { + AutocloseAction.Close -> onDismiss() + AutocloseAction.Confirm -> viewModel.onConfirm() + is AutocloseAction.TakeProfitChanged -> viewModel.onTakeProfitChanged(action.text) + is AutocloseAction.StopLossChanged -> viewModel.onStopLossChanged(action.text) + is AutocloseAction.SelectPercent -> viewModel.onPercentSelected(action.type, action.percent) + } + }, + ) + } + entry { + confirmParams?.let { params -> + ConfirmScreen( + params = params, + cancelAction = popInternal, + finishAction = { hash -> + finishAction(hash) + onDismiss() + }, + onBuy = {}, + handleSystemBack = true, + ) + } + } + } + + val decoratedEntries = rememberDecoratedNavEntries( + entries = backStack.map { entryProvider(it) }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberAutocloseNavEntryDecorator(), + ), + ) + + NavDisplay( + entries = decoratedEntries, + modifier = Modifier.fillMaxHeight(0.95f), + onBack = { + if (backStack.size > 1) popInternal() else onDismiss() + }, + transitionSpec = slideLeftTransition, + popTransitionSpec = slideRightTransition, + predictivePopTransitionSpec = { slideRightTransition() }, + ) +} + +@Serializable +private data object AutocloseRoute : NavKey + +@Serializable +private data object AutocloseConfirmRoute : NavKey + +private typealias AutocloseNavTransition = + AnimatedContentTransitionScope>.() -> ContentTransform + +private val slideLeftTransition: AutocloseNavTransition = { + navigationSlideTransition(AnimatedContentTransitionScope.SlideDirection.Left) +} + +private val slideRightTransition: AutocloseNavTransition = { + navigationSlideTransition(AnimatedContentTransitionScope.SlideDirection.Right) +} + +@Composable +private fun rememberAutocloseRootViewModelStoreOwner(): ViewModelStoreOwner { + val parentOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner via LocalViewModelStoreOwner" + } + val savedStateRegistryOwner = checkNotNull(parentOwner as? SavedStateRegistryOwner) { + "Parent ViewModelStoreOwner must implement SavedStateRegistryOwner" + } + val parentArgs = (parentOwner as? HasDefaultViewModelProviderFactory) + ?.defaultViewModelCreationExtras + ?.get(DEFAULT_ARGS_KEY) + ?: savedState() + val store = remember { ViewModelStore() } + DisposableEffect(store) { + onDispose { store.clear() } + } + return remember(parentOwner, store, savedStateRegistryOwner, parentArgs) { + NavEntryViewModelStoreOwner( + parent = parentOwner, + store = store, + savedStateRegistryOwner = savedStateRegistryOwner, + defaultArgs = parentArgs, + ) + } +} + +@Composable +private fun rememberAutocloseNavEntryDecorator(): NavEntryDecorator { + val parentOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner via LocalViewModelStoreOwner" + } + val savedStateRegistryOwner = checkNotNull(parentOwner as? SavedStateRegistryOwner) { + "Parent ViewModelStoreOwner must implement SavedStateRegistryOwner" + } + val stores = remember { mutableMapOf() } + DisposableEffect(Unit) { + onDispose { + stores.values.forEach(ViewModelStore::clear) + stores.clear() + } + } + return remember(parentOwner, savedStateRegistryOwner) { + AutocloseNavEntryDecorator(parentOwner, savedStateRegistryOwner, stores) + } +} + +private class AutocloseNavEntryDecorator( + private val parent: ViewModelStoreOwner, + private val savedStateRegistryOwner: SavedStateRegistryOwner, + private val stores: MutableMap, +) : NavEntryDecorator( + onPop = { contentKey -> stores.remove(contentKey)?.clear() }, + decorate = { entry -> + val store = remember(entry.contentKey) { + stores.getOrPut(entry.contentKey) { ViewModelStore() } + } + val owner = remember(parent, store, savedStateRegistryOwner) { + NavEntryViewModelStoreOwner( + parent = parent, + store = store, + savedStateRegistryOwner = savedStateRegistryOwner, + defaultArgs = savedState(), + ) + } + CompositionLocalProvider(LocalViewModelStoreOwner provides owner) { + entry.Content() + } + }, +) diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseScene.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseScene.kt new file mode 100644 index 0000000000..3652e69850 --- /dev/null +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/autoclose/AutocloseScene.kt @@ -0,0 +1,134 @@ +package com.gemwallet.android.features.perpetual.views.autoclose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.gemwallet.android.features.perpetual.views.components.PerpetualPositionItem +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.buttons.MainActionButton +import com.gemwallet.android.ui.components.list_item.property.PropertyItem +import com.gemwallet.android.ui.components.perpetual.AutocloseInputSection +import com.gemwallet.android.ui.components.perpetual.AutocloseSuggestionsBar +import com.gemwallet.android.ui.icons.AppIcons +import com.gemwallet.android.ui.models.ListPosition +import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModel +import com.gemwallet.android.ui.theme.Spacer16 +import com.gemwallet.android.ui.theme.paddingDefault +import com.wallet.core.primitives.TpslType + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AutocloseScene( + model: AutocloseUIModel, + takeProfitText: String, + stopLossText: String, + onAction: (AutocloseAction) -> Unit, +) { + var focusedField: TpslType? by remember { mutableStateOf(null) } + + val activeField = focusedField?.let { type -> + when (type) { + TpslType.TakeProfit -> model.takeProfit + TpslType.StopLoss -> model.stopLoss + } + } + val activeText = when (focusedField) { + TpslType.TakeProfit -> takeProfitText + TpslType.StopLoss -> stopLossText + null -> "" + } + + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .imePadding(), + ) { + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(R.string.perpetual_auto_close), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = { onAction(AutocloseAction.Close) }) { + Icon(imageVector = AppIcons.Close, contentDescription = null) + } + }, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = paddingDefault), + ) { + PerpetualPositionItem( + data = model.position, + listPosition = ListPosition.Single, + ) + Spacer16() + PropertyItem( + title = stringResource(R.string.perpetual_entry_price), + data = model.entryPriceText, + listPosition = ListPosition.First, + ) + PropertyItem( + title = stringResource(R.string.perpetual_market_price), + data = model.marketPriceText, + listPosition = ListPosition.Last, + ) + Spacer16() + AutocloseInputSection( + field = model.takeProfit, + text = takeProfitText, + onTextChanged = { onAction(AutocloseAction.TakeProfitChanged(it)) }, + onFocusChanged = { focused -> + if (focused) focusedField = TpslType.TakeProfit + else if (focusedField == TpslType.TakeProfit) focusedField = null + }, + ) + Spacer16() + AutocloseInputSection( + field = model.stopLoss, + text = stopLossText, + onTextChanged = { onAction(AutocloseAction.StopLossChanged(it)) }, + onFocusChanged = { focused -> + if (focused) focusedField = TpslType.StopLoss + else if (focusedField == TpslType.StopLoss) focusedField = null + }, + ) + Spacer(Modifier.weight(1f)) + if (activeField != null && activeText.isEmpty()) { + AutocloseSuggestionsBar( + suggestions = activeField.percentSuggestions, + onPercentSelected = { percent -> onAction(AutocloseAction.SelectPercent(activeField.type, percent)) }, + ) + Spacer16() + } + MainActionButton( + title = stringResource(R.string.transfer_confirm), + enabled = model.confirmEnabled, + onClick = { onAction(AutocloseAction.Confirm) }, + ) + Spacer16() + } + } +} diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/components/PositionProperties.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/components/PositionProperties.kt index db5663daf4..f5a014b379 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/components/PositionProperties.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/components/PositionProperties.kt @@ -1,20 +1,31 @@ package com.gemwallet.android.features.perpetual.views.components +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.gemwallet.android.domains.perpetual.aggregates.PerpetualPositionDetailsDataAggregate import com.gemwallet.android.model.CurrencyFormatter import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.InfoSheetEntity +import com.gemwallet.android.ui.components.list_item.ListItemSupportText import com.gemwallet.android.ui.components.list_item.SubheaderItem import com.gemwallet.android.ui.components.list_item.color +import com.gemwallet.android.ui.components.list_item.property.DataBadgeChevron import com.gemwallet.android.ui.components.list_item.property.PropertyItem +import com.gemwallet.android.ui.components.list_item.property.PropertyTitleText import com.gemwallet.android.ui.models.ListPosition +import com.gemwallet.android.ui.theme.paddingMiddle import com.wallet.core.primitives.Currency import com.wallet.core.primitives.PerpetualMarginType -internal fun LazyListScope.positionProperties(position: PerpetualPositionDetailsDataAggregate?) { +internal fun LazyListScope.positionProperties( + position: PerpetualPositionDetailsDataAggregate?, + onAutocloseClick: () -> Unit, +) { if (position == null) { return } @@ -29,12 +40,7 @@ internal fun LazyListScope.positionProperties(position: PerpetualPositionDetails dataColor = position.pnlState.color(), listPosition = ListPosition.Middle, ) - PropertyItem( - title = stringResource(R.string.perpetual_auto_close), - data = position.autoCloseText(), - info = InfoSheetEntity.AutoCloseInfo, - listPosition = ListPosition.Middle, - ) + AutocloseRow(position = position, onClick = onAutocloseClick) PropertyItem( title = stringResource(R.string.perpetual_size), data = position.size, @@ -69,20 +75,41 @@ internal fun LazyListScope.positionProperties(position: PerpetualPositionDetails } @Composable -private fun PerpetualPositionDetailsDataAggregate.autoCloseText(): String { - val takeProfit = takeProfit.formatTriggerOrder(stringResource(R.string.charts_take_profit)) - val stopLoss = stopLoss.formatTriggerOrder(stringResource(R.string.charts_stop_loss)) - return when { - takeProfit != null && stopLoss != null -> "$takeProfit / $stopLoss" - takeProfit != null -> takeProfit - stopLoss != null -> stopLoss - else -> "-" - } +private fun AutocloseRow( + position: PerpetualPositionDetailsDataAggregate, + onClick: () -> Unit, +) { + val takeProfitText = position.takeProfit.formatTriggerOrder(stringResource(R.string.charts_take_profit)) + val stopLossText = position.stopLoss.formatTriggerOrder(stringResource(R.string.charts_stop_loss)) + PropertyItem( + modifier = Modifier.clickable(onClick = onClick), + title = { + PropertyTitleText( + text = stringResource(R.string.perpetual_auto_close), + info = InfoSheetEntity.AutoCloseInfo, + ) + }, + data = { + Column(horizontalAlignment = Alignment.End) { + when { + takeProfitText != null && stopLossText != null -> { + ListItemSupportText(takeProfitText) + ListItemSupportText(stopLossText) + } + takeProfitText != null -> ListItemSupportText(takeProfitText) + stopLossText != null -> ListItemSupportText(stopLossText) + else -> ListItemSupportText("-") + } + } + DataBadgeChevron() + }, + listPosition = ListPosition.Middle, + ) } @Composable private fun PerpetualPositionDetailsDataAggregate.marginText(): String { - return "${marginAmount} (${marginType.title()})" + return "$marginAmount (${marginType.title()})" } @Composable diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt index dd75ba52ac..a0e8981336 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt @@ -8,12 +8,12 @@ import androidx.compose.runtime.snapshotFlow import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.gemwallet.android.features.perpetual.viewmodels.PerpetualMarketViewModel -import com.wallet.core.primitives.AssetId +import com.gemwallet.android.ui.models.actions.AssetIdAction @Composable fun PerpetualMarketNavScreen( onCancel: () -> Unit, - onOpenPerpetualDetails: (AssetId) -> Unit, + onOpenPerpetualDetails: AssetIdAction, viewModel: PerpetualMarketViewModel = hiltViewModel(), ) { val sceneState by viewModel.sceneState.collectAsStateWithLifecycle() diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualDetailsAction.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualDetailsAction.kt index 1751979c45..7eaf706495 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualDetailsAction.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualDetailsAction.kt @@ -10,6 +10,7 @@ internal sealed interface PerpetualDetailsAction { data object IncreasePosition : PerpetualDetailsAction data object ReducePosition : PerpetualDetailsAction data object ClosePosition : PerpetualDetailsAction + data object Autoclose : PerpetualDetailsAction data class OpenPosition(val direction: PerpetualDirection) : PerpetualDetailsAction data class SelectChartPeriod(val period: ChartPeriod) : PerpetualDetailsAction data class OpenTransaction(val transactionId: TransactionId) : PerpetualDetailsAction diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt index 82880e6e77..8355c5d775 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt @@ -3,11 +3,17 @@ package com.gemwallet.android.features.perpetual.views.position import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.gemwallet.android.features.perpetual.viewmodels.PerpetualDetailsViewModel +import com.gemwallet.android.features.perpetual.views.autoclose.AutocloseNavGraph +import com.gemwallet.android.ui.components.screen.ModalBottomSheet import com.gemwallet.android.ui.models.actions.AmountTransactionAction import com.gemwallet.android.ui.models.actions.ConfirmTransactionAction +import com.gemwallet.android.ui.models.actions.FinishConfirmAction import com.wallet.core.primitives.TransactionId @Composable @@ -27,6 +33,7 @@ fun PerpetualPositionNavScreen( val chartState by viewModel.chartState.collectAsStateWithLifecycle() val period by viewModel.period.collectAsStateWithLifecycle() val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + var showAutoclose by remember { mutableStateOf(false) } PerpetualPositionScene( perpetual = perpetual, @@ -39,14 +46,28 @@ fun PerpetualPositionNavScreen( onAction = { action -> when (action) { PerpetualDetailsAction.Close -> onClose() - PerpetualDetailsAction.Refresh -> viewModel.fetch() + PerpetualDetailsAction.Refresh -> viewModel.refresh() PerpetualDetailsAction.IncreasePosition -> viewModel.increasePosition(amountAction) PerpetualDetailsAction.ReducePosition -> viewModel.reducePosition(amountAction) PerpetualDetailsAction.ClosePosition -> viewModel.closePosition(confirmAction) + PerpetualDetailsAction.Autoclose -> showAutoclose = true is PerpetualDetailsAction.OpenPosition -> viewModel.openPosition(action.direction, amountAction) is PerpetualDetailsAction.SelectChartPeriod -> viewModel.period(action.period) is PerpetualDetailsAction.OpenTransaction -> onTransaction(action.transactionId) } }, ) + + ModalBottomSheet( + isVisible = showAutoclose, + onDismissRequest = { showAutoclose = false }, + skipPartiallyExpanded = true, + title = null, + dragHandle = null, + ) { + AutocloseNavGraph( + onDismiss = { showAutoclose = false }, + finishAction = FinishConfirmAction { _ -> viewModel.fetch() }, + ) + } } diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionScene.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionScene.kt index 9123c2268a..96b2bdf9bb 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionScene.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionScene.kt @@ -88,7 +88,7 @@ internal fun PerpetualPositionScene( onPeriodSelect = { onAction(PerpetualDetailsAction.SelectChartPeriod(it)) }, ) } - positionProperties(position) + positionProperties(position, onAutocloseClick = { onAction(PerpetualDetailsAction.Autoclose) }) item { if (perpetual != null) { if (position == null) { diff --git a/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/AutocloseViewModel.kt b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/AutocloseViewModel.kt new file mode 100644 index 0000000000..8889fb6ae8 --- /dev/null +++ b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/AutocloseViewModel.kt @@ -0,0 +1,207 @@ +package com.gemwallet.android.features.perpetual.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gemwallet.android.application.perpetual.coordinators.BuildPerpetualParams +import com.gemwallet.android.data.repositories.perpetual.PerpetualRepository +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseEstimator +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseField +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseModifyBuilder +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseValidator +import com.gemwallet.android.ext.PerpetualFormatter +import com.gemwallet.android.model.ConfirmParams +import com.gemwallet.android.model.NumericFormatter +import com.gemwallet.android.ui.models.navigation.requireAssetId +import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModel +import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModelFactory +import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.PerpetualPositionData +import com.wallet.core.primitives.TpslType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.text.DecimalFormatSymbols +import java.util.Locale +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class AutocloseViewModel @Inject constructor( + private val perpetualRepository: PerpetualRepository, + private val buildPerpetualParams: BuildPerpetualParams, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val assetId: AssetId = savedStateHandle.requireAssetId() + + private val numericFormatter = NumericFormatter() + + val position: StateFlow = perpetualRepository.getPerpetualByAssetId(assetId) + .distinctUntilChanged() + .flatMapLatest { data -> + data?.let { perpetualRepository.getPositionByPerpetualId(it.perpetual.id) } ?: flowOf(null) + } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val _confirmRequests = MutableSharedFlow(extraBufferCapacity = 1) + val confirmRequests: SharedFlow = _confirmRequests + + private val userTakeProfitText = MutableStateFlow(null) + private val userStopLossText = MutableStateFlow(null) + + val takeProfitText: StateFlow = combine(userTakeProfitText, position) { user, pos -> + user ?: initialText(pos, TpslType.TakeProfit) + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + val stopLossText: StateFlow = combine(userStopLossText, position) { user, pos -> + user ?: initialText(pos, TpslType.StopLoss) + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + private val submitAttempted = MutableStateFlow(false) + + val uiModel: StateFlow = combine( + position, + takeProfitText, + stopLossText, + submitAttempted, + ) { position, takeProfit, stopLoss, attempted -> + position?.let { buildUiModel(it, takeProfit, stopLoss, attempted) } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + fun onTakeProfitChanged(text: String) { + submitAttempted.value = false + userTakeProfitText.value = text.filterNumeric() + } + + fun onStopLossChanged(text: String) { + submitAttempted.value = false + userStopLossText.value = text.filterNumeric() + } + + fun onPercentSelected(type: TpslType, percent: Int) { + submitAttempted.value = false + val position = position.value ?: return + val estimator = estimator(position) + val target = estimator.targetPriceFromRoe(percent, type) + val formatted = PerpetualFormatter.formatInputPrice( + provider = position.perpetual.provider, + price = target, + decimals = position.asset.decimals, + ) + when (type) { + TpslType.TakeProfit -> userTakeProfitText.value = formatted + TpslType.StopLoss -> userStopLossText.value = formatted + } + } + + fun onConfirm() { + submitAttempted.value = true + val position = position.value ?: return + val perpetualId = position.perpetual.id + val assetIndex = position.perpetual.identifier.toIntOrNull() ?: return + val takeProfitField = autocloseField(position, TpslType.TakeProfit, takeProfitText.value) + val stopLossField = autocloseField(position, TpslType.StopLoss, stopLossText.value) + val builder = AutocloseModifyBuilder(position.position.direction) + if (!builder.canBuild(takeProfitField, stopLossField)) return + val modifyTypes = builder.build(assetIndex, takeProfitField, stopLossField) + viewModelScope.launch { + buildPerpetualParams.modify( + perpetualId = perpetualId, + modifyTypes = modifyTypes, + takeProfitOrderId = takeProfitField.orderId, + stopLossOrderId = stopLossField.orderId, + )?.let { _confirmRequests.tryEmit(it) } + } + } + + private fun buildUiModel( + position: PerpetualPositionData, + takeProfitText: String, + stopLossText: String, + submitAttempted: Boolean, + ): AutocloseUIModel { + val takeProfit = autocloseField(position, TpslType.TakeProfit, takeProfitText) + val stopLoss = autocloseField(position, TpslType.StopLoss, stopLossText) + val builder = AutocloseModifyBuilder(position.position.direction) + val confirmEnabled = if (submitAttempted) { + builder.canBuild(takeProfit, stopLoss) + } else { + takeProfit.hasPendingChange || stopLoss.hasPendingChange + } + return AutocloseUIModelFactory.create( + position = position, + takeProfit = takeProfit, + stopLoss = stopLoss, + confirmEnabled = confirmEnabled, + showErrors = submitAttempted, + ) + } + + private fun autocloseField( + position: PerpetualPositionData, + type: TpslType, + text: String, + ): AutocloseField { + val price = numericFormatter.double(text) + val original = when (type) { + TpslType.TakeProfit -> position.position.takeProfit + TpslType.StopLoss -> position.position.stopLoss + } + val validator = AutocloseValidator( + type = type, + direction = position.position.direction, + marketPrice = position.perpetual.price, + ) + return AutocloseField( + type = type, + price = price, + originalPrice = original?.price, + formattedPrice = price?.let { + PerpetualFormatter.formatPrice(position.perpetual.provider, it, position.asset.decimals) + }, + error = validator.error(price), + orderId = original?.order_id?.toULongOrNull(), + ) + } + + private fun estimator(position: PerpetualPositionData) = AutocloseEstimator( + entryPrice = position.position.entryPrice, + positionSize = position.position.size, + direction = position.position.direction, + leverage = position.position.leverage, + ) + + private fun initialText(position: PerpetualPositionData?, type: TpslType): String { + val trigger = position?.let { + when (type) { + TpslType.TakeProfit -> it.position.takeProfit + TpslType.StopLoss -> it.position.stopLoss + } + } ?: return "" + return PerpetualFormatter.formatInputPrice( + provider = position.perpetual.provider, + price = trigger.price, + decimals = position.asset.decimals, + ) + } + + private fun String.filterNumeric(locale: Locale = Locale.getDefault()): String { + val separator = DecimalFormatSymbols.getInstance(locale).decimalSeparator + return filter { it.isDigit() || it == separator || it == '.' } + } +} diff --git a/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualDetailsViewModel.kt b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualDetailsViewModel.kt index 462762b888..71c443b494 100644 --- a/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualDetailsViewModel.kt +++ b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualDetailsViewModel.kt @@ -58,7 +58,7 @@ class PerpetualDetailsViewModel @Inject constructor( const val SubscriptionGraceMillis = 5_000L } - private val assetId = savedStateHandle.requireAssetId() + val assetId = savedStateHandle.requireAssetId() private val transactionFilters = listOf( TransactionsRequestFilter.Asset(assetId), @@ -134,13 +134,17 @@ class PerpetualDetailsViewModel @Inject constructor( } fun fetch() { - refreshState.value = true refreshTrigger.update { it + 1 } viewModelScope.launch(Dispatchers.IO) { syncPerpetualPositions.syncPerpetualPositions() } } + fun refresh() { + refreshState.value = true + fetch() + } + fun openPosition(direction: PerpetualDirection, amountAction: AmountTransactionAction) { val perpetualId = perpetual.value?.id ?: return viewModelScope.launch { diff --git a/android/features/settings/settings/presents/src/main/kotlin/com/gemwallet/android/features/settings/settings/presents/views/PreferencesScene.kt b/android/features/settings/settings/presents/src/main/kotlin/com/gemwallet/android/features/settings/settings/presents/views/PreferencesScene.kt index 7648fd3bc2..3a3d037549 100644 --- a/android/features/settings/settings/presents/src/main/kotlin/com/gemwallet/android/features/settings/settings/presents/views/PreferencesScene.kt +++ b/android/features/settings/settings/presents/src/main/kotlin/com/gemwallet/android/features/settings/settings/presents/views/PreferencesScene.kt @@ -51,12 +51,12 @@ fun PreferencesScene( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val isPerpetualEnabled by viewModel.isPerpetualEnabled.collectAsStateWithLifecycle() val perpetualLeverage by viewModel.perpetualLeverage.collectAsStateWithLifecycle() + val perpetualTakeProfit by viewModel.perpetualTakeProfit.collectAsStateWithLifecycle() + val perpetualStopLoss by viewModel.perpetualStopLoss.collectAsStateWithLifecycle() val configuration = LocalConfiguration.current val context = LocalContext.current - var showLeveragePicker by remember { mutableStateOf(false) } - Scene( title = stringResource(id = (R.string.settings_preferences_title)), onClose = onCancel, @@ -111,63 +111,103 @@ fun PreferencesScene( } } - if (uiState.developEnabled) { + item { + LinkItem( + title = stringResource(id = R.string.perpetuals_title), + icon = R.drawable.settings_pricealert, + listPosition = if (isPerpetualEnabled) ListPosition.First else ListPosition.Single, + trailingContent = { + Switch( + checked = isPerpetualEnabled, + onCheckedChange = viewModel::setPerpetualEnabled, + ) + }, + onClick = { viewModel.setPerpetualEnabled(!isPerpetualEnabled) }, + ) + } + + if (isPerpetualEnabled) { item { - LinkItem( - title = stringResource(id = R.string.perpetuals_title), - icon = R.drawable.settings_pricealert, - listPosition = if (isPerpetualEnabled) ListPosition.First else ListPosition.Single, - trailingContent = { - Switch( - checked = isPerpetualEnabled, - onCheckedChange = viewModel::setPerpetualEnabled, - ) - }, - onClick = { viewModel.setPerpetualEnabled(!isPerpetualEnabled) }, + OptionPickerLinkItem( + title = stringResource(R.string.settings_preferences_default_leverage), + current = perpetualLeverage, + options = PerpetualConfig.leverageOptions, + listPosition = ListPosition.Middle, + label = { it.formatLeverage() }, + onSelect = { viewModel.setPerpetualLeverage(it) }, ) } - } - - if (uiState.developEnabled && isPerpetualEnabled) { item { - LinkItem( - title = stringResource(id = R.string.settings_preferences_default_leverage), + OptionPickerLinkItem( + title = stringResource(R.string.settings_preferences_default_take_profit), + current = perpetualTakeProfit, + options = PerpetualConfig.takeProfitOptions, + listPosition = ListPosition.Middle, + label = { autocloseLabel(it) }, + onSelect = { viewModel.setPerpetualTakeProfit(it) }, + ) + } + item { + OptionPickerLinkItem( + title = stringResource(R.string.settings_preferences_default_stop_loss), + current = perpetualStopLoss, + options = PerpetualConfig.stopLossOptions, listPosition = ListPosition.Last, - trailingContent = { - PropertyDataText( - text = perpetualLeverage.formatLeverage(), - badge = { DataBadgeChevron() }, - ) - DropdownMenu( - expanded = showLeveragePicker, - onDismissRequest = { showLeveragePicker = false }, - containerColor = MaterialTheme.colorScheme.background, - ) { - PerpetualConfig.leverageOptions.forEach { value -> - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (value == perpetualLeverage) { - Icon(AppIcons.Check, null, modifier = Modifier.size(compactIconSize)) - } else { - Spacer(modifier = Modifier.size(compactIconSize)) - } - Spacer4() - Text(value.formatLeverage()) - } - }, - onClick = { - viewModel.setPerpetualLeverage(value) - showLeveragePicker = false - }, - ) + label = { autocloseLabel(it) }, + onSelect = { viewModel.setPerpetualStopLoss(it) }, + ) + } + } + } + } +} + +@Composable +private fun autocloseLabel(percent: Int): String = + if (percent == 0) stringResource(R.string.common_none) else "$percent%" + +@Composable +private fun OptionPickerLinkItem( + title: String, + current: T, + options: List, + listPosition: ListPosition, + label: @Composable (T) -> String, + onSelect: (T) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + LinkItem( + title = title, + listPosition = listPosition, + indented = true, + trailingContent = { + PropertyDataText(text = label(current), badge = { DataBadgeChevron() }) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + containerColor = MaterialTheme.colorScheme.background, + ) { + options.forEach { option -> + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (option == current) { + Icon(AppIcons.Check, null, modifier = Modifier.size(compactIconSize)) + } else { + Spacer(modifier = Modifier.size(compactIconSize)) } + Spacer4() + Text(label(option)) } }, - onClick = { showLeveragePicker = true }, + onClick = { + onSelect(option) + expanded = false + }, ) } } - } - } + }, + onClick = { expanded = true }, + ) } diff --git a/android/features/settings/settings/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/settings/viewmodels/SettingsViewModel.kt b/android/features/settings/settings/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/settings/viewmodels/SettingsViewModel.kt index 5f261803b0..a186fe9845 100644 --- a/android/features/settings/settings/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/settings/viewmodels/SettingsViewModel.kt +++ b/android/features/settings/settings/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/settings/viewmodels/SettingsViewModel.kt @@ -69,6 +69,20 @@ class SettingsViewModel @Inject constructor( userConfig.setPerpetualLeverage(value) } + val perpetualTakeProfit = userConfig.perpetualTakeProfit() + .stateIn(viewModelScope, SharingStarted.Eagerly, PerpetualConfig.defaultTakeProfit) + + fun setPerpetualTakeProfit(value: Int) = viewModelScope.launch(Dispatchers.IO) { + userConfig.setPerpetualTakeProfit(value) + } + + val perpetualStopLoss = userConfig.perpetualStopLoss() + .stateIn(viewModelScope, SharingStarted.Eagerly, PerpetualConfig.defaultStopLoss) + + fun setPerpetualStopLoss(value: Int) = viewModelScope.launch(Dispatchers.IO) { + userConfig.setPerpetualStopLoss(value) + } + init { viewModelScope.launch { session.collectLatest { diff --git a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/AmountScreen.kt b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/AmountScreen.kt index 39cd2352ed..ecde234c40 100644 --- a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/AmountScreen.kt +++ b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/AmountScreen.kt @@ -78,7 +78,13 @@ fun AmountScreen( onInputTypeClick = viewModel::switchInputType, onMaxAmount = viewModel::onMaxAmount, onCancel = onCancel, - additionParams = { ProviderExtras(provider, onPickValidator = { isSelectValidator = true }) }, + additionParams = { + ProviderExtras( + provider = provider, + amount = viewModel.amount, + onPickValidator = { isSelectValidator = true }, + ) + }, ) } } diff --git a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/ProviderExtras.kt b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/ProviderExtras.kt index e55c7ce926..e3d797830b 100644 --- a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/ProviderExtras.kt +++ b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/ProviderExtras.kt @@ -7,19 +7,24 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.gemwallet.android.domains.perpetual.formatLeverage +import com.gemwallet.android.features.transfer_amount.presents.dialogs.AmountAutocloseSheet import com.gemwallet.android.features.transfer_amount.presents.dialogs.SelectLeverageDialog import com.gemwallet.android.features.transfer_amount.viewmodels.providers.AmountDataProvider import com.gemwallet.android.features.transfer_amount.viewmodels.providers.AmountPerpetualProvider import com.gemwallet.android.features.transfer_amount.viewmodels.providers.AmountStakeProvider import com.gemwallet.android.features.transfer_amount.viewmodels.providers.AmountTransferProvider import com.gemwallet.android.model.AmountParams +import com.gemwallet.android.model.CurrencyFormatter import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.InfoSheetEntity import com.gemwallet.android.ui.components.TabsBar import com.gemwallet.android.ui.components.clickable +import com.gemwallet.android.ui.components.list_item.ListItemSupportText import com.gemwallet.android.ui.components.list_item.SubheaderItem import com.gemwallet.android.ui.components.list_item.property.DataBadgeChevron import com.gemwallet.android.ui.components.list_item.property.PropertyDataText @@ -28,17 +33,24 @@ import com.gemwallet.android.ui.components.list_item.property.PropertyTitleText import com.gemwallet.android.ui.components.list_item.property.PropertyValidatorItem import com.gemwallet.android.ui.components.perpetual.color import com.gemwallet.android.ui.models.ListPosition +import com.wallet.core.primitives.Currency import com.wallet.core.primitives.Resource @Composable fun ProviderExtras( provider: AmountDataProvider, + amount: String, onPickValidator: () -> Unit, ) { Column { when (provider) { is AmountStakeProvider -> StakeProviderSection(provider, onPickValidator) - is AmountPerpetualProvider -> PerpetualLeverageSection(provider) + is AmountPerpetualProvider -> { + PerpetualLeverageSection(provider) + if (provider.showsAutoclose) { + PerpetualAutocloseSection(provider, amount) + } + } is AmountTransferProvider -> Unit } } @@ -108,3 +120,53 @@ private fun PerpetualLeverageSection(provider: AmountPerpetualProvider) { onSelect = provider::setLeverage, ) } + +@Composable +private fun PerpetualAutocloseSection(provider: AmountPerpetualProvider, amount: String) { + val takeProfit by provider.takeProfit.collectAsStateWithLifecycle() + val stopLoss by provider.stopLoss.collectAsStateWithLifecycle() + var sheetVisible by remember { mutableStateOf(false) } + + PropertyItem( + modifier = Modifier.clickable { sheetVisible = true }, + title = { + PropertyTitleText( + text = stringResource(R.string.perpetual_auto_close), + info = InfoSheetEntity.AutoCloseInfo, + ) + }, + data = { + AutocloseRowValue(takeProfit = takeProfit, stopLoss = stopLoss) + DataBadgeChevron() + }, + listPosition = ListPosition.Single, + ) + + AmountAutocloseSheet( + isVisible = sheetVisible, + provider = provider, + amount = amount, + onDismiss = { sheetVisible = false }, + ) +} + +private val usdFormatter = CurrencyFormatter(currency = Currency.USD) + +@Composable +private fun AutocloseRowValue(takeProfit: String?, stopLoss: String?) { + val tpLabel = stringResource(R.string.charts_take_profit) + val slLabel = stringResource(R.string.charts_stop_loss) + val tpText = takeProfit?.toDoubleOrNull()?.let { "$tpLabel: ${usdFormatter.string(it)}" } + val slText = stopLoss?.toDoubleOrNull()?.let { "$slLabel: ${usdFormatter.string(it)}" } + Column(horizontalAlignment = Alignment.End) { + when { + tpText != null && slText != null -> { + ListItemSupportText(tpText) + ListItemSupportText(slText) + } + tpText != null -> ListItemSupportText(tpText) + slText != null -> ListItemSupportText(slText) + else -> ListItemSupportText("-") + } + } +} diff --git a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/AmountAutocloseSheet.kt b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/AmountAutocloseSheet.kt new file mode 100644 index 0000000000..8b69621956 --- /dev/null +++ b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/AmountAutocloseSheet.kt @@ -0,0 +1,203 @@ +package com.gemwallet.android.features.transfer_amount.presents.dialogs + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseError +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseEstimator +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseField +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseValidator +import com.gemwallet.android.ext.PerpetualFormatter +import com.gemwallet.android.features.transfer_amount.viewmodels.providers.AmountPerpetualProvider +import com.gemwallet.android.math.parseNumberOrNull +import com.gemwallet.android.model.CurrencyFormatter +import com.gemwallet.android.model.NumericFormatter +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.buttons.MainActionButton +import com.gemwallet.android.ui.components.list_item.property.PropertyItem +import com.gemwallet.android.ui.components.perpetual.AutocloseInputSection +import com.gemwallet.android.ui.components.perpetual.AutocloseSuggestionsBar +import com.gemwallet.android.ui.components.screen.ModalBottomSheet +import com.gemwallet.android.ui.models.ListPosition +import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModel +import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModelFactory +import com.gemwallet.android.ui.theme.Spacer16 +import com.gemwallet.android.ui.theme.paddingDefault +import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.TpslType + +@Composable +internal fun AmountAutocloseSheet( + isVisible: Boolean, + provider: AmountPerpetualProvider, + amount: String, + onDismiss: () -> Unit, +) { + if (!isVisible) return + val perpetual = provider.perpetual.collectAsStateWithLifecycle().value ?: run { + onDismiss() + return + } + val storedTakeProfit by provider.takeProfit.collectAsStateWithLifecycle() + val storedStopLoss by provider.stopLoss.collectAsStateWithLifecycle() + val leverageState by provider.leverageState.collectAsStateWithLifecycle() + + val direction = provider.direction + val marketPrice = perpetual.price + val assetDecimals = perpetual.asset.decimals + val perpetualProvider = perpetual.provider + val leverage = leverageState?.current ?: 1 + val marketPriceText = usdFormatter.string(marketPrice) + val sizeText = usdFormatter.string((amount.parseNumberOrNull()?.toDouble() ?: 0.0) * leverage) + + var takeProfitText by remember { mutableStateOf(storedTakeProfit.orEmpty()) } + var stopLossText by remember { mutableStateOf(storedStopLoss.orEmpty()) } + var submitAttempted by remember { mutableStateOf(false) } + var focused: TpslType? by remember { mutableStateOf(null) } + + val numericFormatter = remember { NumericFormatter() } + val estimator = provider.estimatorFor(amount) + val takeProfitPrice = numericFormatter.double(takeProfitText) + val stopLossPrice = numericFormatter.double(stopLossText) + val takeProfitRawError = AutocloseValidator(TpslType.TakeProfit, direction, marketPrice).error(takeProfitPrice) + val stopLossRawError = AutocloseValidator(TpslType.StopLoss, direction, marketPrice).error(stopLossPrice) + val takeProfitField = buildField(TpslType.TakeProfit, takeProfitPrice, takeProfitRawError, estimator, submitAttempted) + val stopLossField = buildField(TpslType.StopLoss, stopLossPrice, stopLossRawError, estimator, submitAttempted) + + val activeField = focused?.let { + when (it) { + TpslType.TakeProfit -> takeProfitField + TpslType.StopLoss -> stopLossField + } + } + val activeText = when (focused) { + TpslType.TakeProfit -> takeProfitText + TpslType.StopLoss -> stopLossText + null -> "" + } + val isTakeProfitValid = takeProfitText.isEmpty() || takeProfitRawError == null + val isStopLossValid = stopLossText.isEmpty() || stopLossRawError == null + val hasInput = takeProfitPrice != null || stopLossPrice != null || + (takeProfitText.isEmpty() && stopLossText.isEmpty()) + val confirmEnabled = if (submitAttempted) isTakeProfitValid && isStopLossValid else hasInput + + ModalBottomSheet( + isVisible = isVisible, + onDismissRequest = onDismiss, + skipPartiallyExpanded = true, + title = stringResource(R.string.perpetual_auto_close), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(horizontal = paddingDefault) + .imePadding(), + ) { + OpenPositionItem( + asset = perpetual.asset, + direction = direction, + leverage = leverage, + sizeText = sizeText, + listPosition = ListPosition.Single, + ) + Spacer16() + PropertyItem( + title = stringResource(R.string.perpetual_market_price), + data = marketPriceText, + listPosition = ListPosition.Single, + ) + Spacer16() + AutocloseInputSection( + field = takeProfitField, + text = takeProfitText, + onTextChanged = { + submitAttempted = false + takeProfitText = it + }, + onFocusChanged = { hasFocus -> + if (hasFocus) focused = TpslType.TakeProfit + else if (focused == TpslType.TakeProfit) focused = null + }, + ) + Spacer16() + AutocloseInputSection( + field = stopLossField, + text = stopLossText, + onTextChanged = { + submitAttempted = false + stopLossText = it + }, + onFocusChanged = { hasFocus -> + if (hasFocus) focused = TpslType.StopLoss + else if (focused == TpslType.StopLoss) focused = null + }, + ) + Spacer(Modifier.weight(1f)) + if (activeField != null && activeText.isEmpty()) { + AutocloseSuggestionsBar( + suggestions = activeField.percentSuggestions, + onPercentSelected = { percent -> + val target = estimator.targetPriceFromRoe(percent, activeField.type) + val formatted = PerpetualFormatter.formatInputPrice( + provider = perpetualProvider, + price = target, + decimals = assetDecimals, + ) + submitAttempted = false + when (activeField.type) { + TpslType.TakeProfit -> takeProfitText = formatted + TpslType.StopLoss -> stopLossText = formatted + } + }, + ) + Spacer16() + } + MainActionButton( + title = stringResource(R.string.common_done), + enabled = confirmEnabled, + onClick = { + submitAttempted = true + if (isTakeProfitValid && isStopLossValid) { + provider.setTakeProfit(takeProfitText.takeIf { it.isNotEmpty() }) + provider.setStopLoss(stopLossText.takeIf { it.isNotEmpty() }) + onDismiss() + } + }, + ) + Spacer16() + } + } +} + +private val usdFormatter = CurrencyFormatter(type = CurrencyFormatter.Type.Currency, currency = Currency.USD) + +private fun buildField( + type: TpslType, + price: Double?, + error: AutocloseError?, + estimator: AutocloseEstimator, + showErrors: Boolean, +): AutocloseUIModel.Field { + val field = AutocloseField( + type = type, + price = price, + originalPrice = null, + formattedPrice = null, + error = error, + orderId = null, + ) + return AutocloseUIModelFactory.createField(field = field, estimator = estimator, showErrors = showErrors) +} diff --git a/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/OpenPositionItem.kt b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/OpenPositionItem.kt new file mode 100644 index 0000000000..ae0dd4e7ad --- /dev/null +++ b/android/features/transfer_amount/presents/src/main/kotlin/com/gemwallet/android/features/transfer_amount/presents/dialogs/OpenPositionItem.kt @@ -0,0 +1,43 @@ +package com.gemwallet.android.features.transfer_amount.presents.dialogs + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.gemwallet.android.ui.components.image.AssetIcon +import com.gemwallet.android.ui.components.list_item.ListItem +import com.gemwallet.android.ui.components.list_item.ListItemDefaults +import com.gemwallet.android.ui.components.list_item.ListItemSupportText +import com.gemwallet.android.ui.components.list_item.ListItemTitleText +import com.gemwallet.android.ui.components.perpetual.color +import com.gemwallet.android.ui.components.perpetual.text +import com.gemwallet.android.ui.models.ListPosition +import com.gemwallet.android.ui.theme.adaptivePadding +import com.gemwallet.android.ui.theme.paddingMiddle +import com.gemwallet.android.ui.theme.space0 +import com.gemwallet.android.ui.theme.space6 +import com.wallet.core.primitives.Asset +import com.wallet.core.primitives.PerpetualDirection + +@Composable +internal fun OpenPositionItem( + asset: Asset, + direction: PerpetualDirection, + leverage: Int, + sizeText: String, + modifier: Modifier = Modifier, + listPosition: ListPosition = ListPosition.Single, +) { + ListItem( + modifier = modifier, + listPosition = listPosition, + minHeight = ListItemDefaults.iconMinHeight, + contentPadding = adaptivePadding(default = paddingMiddle, compact = space6), + titleSubtitleSpacing = space0, + leading = { AssetIcon(asset) }, + title = { ListItemTitleText(asset.symbol) }, + subtitle = { ListItemSupportText(direction.text(leverage), color = direction.color()) }, + trailing = { + ListItemTitleText(text = sizeText, color = MaterialTheme.colorScheme.onSurface) + }, + ) +} diff --git a/android/features/transfer_amount/viewmodels/build.gradle.kts b/android/features/transfer_amount/viewmodels/build.gradle.kts index 80490cf7ce..5053caf471 100644 --- a/android/features/transfer_amount/viewmodels/build.gradle.kts +++ b/android/features/transfer_amount/viewmodels/build.gradle.kts @@ -60,6 +60,7 @@ dependencies { testImplementation(testFixtures(project(":gemcore"))) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk.android) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProvider.kt b/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProvider.kt index c3a2bea54c..d706083ccf 100644 --- a/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProvider.kt +++ b/android/features/transfer_amount/viewmodels/src/main/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProvider.kt @@ -8,32 +8,40 @@ import com.gemwallet.android.domains.perpetual.LeverageState import com.gemwallet.android.domains.perpetual.PerpetualConfig import com.gemwallet.android.domains.perpetual.PerpetualOrderFactory import com.gemwallet.android.domains.perpetual.PerpetualPositionAction +import com.gemwallet.android.domains.perpetual.aggregates.PerpetualDetailsDataAggregate +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseEstimator import com.gemwallet.android.ext.HypercoreUSDC import com.gemwallet.android.ext.PerpetualFormatter import com.gemwallet.android.features.transfer_amount.viewmodels.AmountTitle +import com.gemwallet.android.math.parseNumberOrNull import com.gemwallet.android.model.AmountParams import com.gemwallet.android.model.AssetInfo import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.Crypto +import com.gemwallet.android.model.NumericFormatter +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.TpslType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import java.math.BigInteger @OptIn(ExperimentalCoroutinesApi::class) class AmountPerpetualProvider( private val params: AmountParams.Perpetual, - userConfig: UserConfig, + private val userConfig: UserConfig, getAssetInfo: GetAssetInfo, getPerpetual: GetPerpetual, getPerpetualBalance: GetPerpetualBalance, - scope: CoroutineScope, + private val scope: CoroutineScope, ) : AmountDataProvider { override val title: AmountTitle = AmountTitle.Perpetual(params.positionAction) @@ -41,22 +49,44 @@ class AmountPerpetualProvider( override val canSwitchInputType: Boolean = false override val reserveForFee: BigInteger = BigInteger.ZERO - private val isLeverageSelectable: Boolean = + private val isOpenAction: Boolean = params.positionAction is PerpetualPositionAction.Open - private val perpetual = getPerpetual.getPerpetual(params.perpetualId) - .stateIn(scope, SharingStarted.Eagerly, null) + private val numericFormatter = NumericFormatter() + + val perpetual: StateFlow = + getPerpetual.getPerpetual(params.perpetualId) + .stateIn(scope, SharingStarted.Eagerly, null) + + val direction: PerpetualDirection = params.direction + + private val takeProfitInput = MutableStateFlow(null) + private val stopLossInput = MutableStateFlow(null) + private val takeProfitEdited = MutableStateFlow(false) + private val stopLossEdited = MutableStateFlow(false) + + fun setTakeProfit(value: String?) { + takeProfitEdited.value = true + takeProfitInput.value = value?.takeIf { it.isNotEmpty() } + } + + fun setStopLoss(value: String?) { + stopLossEdited.value = true + stopLossInput.value = value?.takeIf { it.isNotEmpty() } + } + + val showsAutoclose: Boolean = isOpenAction private val userSelectedLeverage = MutableStateFlow(null) - val leverageState: StateFlow = if (isLeverageSelectable) { + val leverageState: StateFlow = if (isOpenAction) { combine( perpetual.filterNotNull(), userConfig.perpetualLeverage(), userSelectedLeverage, ) { current, preferred, override -> val options = PerpetualConfig.leverageOptions - .filter { it <= current.maxLeverage.toInt() } + .filter { it <= current.maxLeverage } LeverageState( current = PerpetualConfig.selectLeverage(override ?: preferred, options), options = options, @@ -69,6 +99,54 @@ class AmountPerpetualProvider( fun setLeverage(value: Int) { userSelectedLeverage.value = value } + fun estimatorFor(amount: String): AutocloseEstimator { + val market = perpetual.value + val leverage = (leverageState.value?.current ?: market?.maxLeverage ?: 1).coerceAtLeast(1) + val marketPrice = market?.price ?: 0.0 + val usdAmount = amount.parseNumberOrNull()?.toDouble() ?: 0.0 + val positionSize = if (marketPrice > 0.0) (usdAmount * leverage) / marketPrice else 0.0 + return AutocloseEstimator( + entryPrice = marketPrice, + positionSize = positionSize, + direction = direction, + leverage = leverage.toUByte(), + ) + } + + val takeProfit: StateFlow = autocloseTrigger(takeProfitInput, takeProfitEdited, TpslType.TakeProfit) + val stopLoss: StateFlow = autocloseTrigger(stopLossInput, stopLossEdited, TpslType.StopLoss) + + private fun autocloseTrigger( + input: StateFlow, + edited: StateFlow, + type: TpslType, + ): StateFlow { + if (!isOpenAction) return input + return combine(input, edited, defaultTrigger(type)) { value, isEdited, default -> + if (isEdited) value else default ?: value + }.stateIn(scope, SharingStarted.Eagerly, null) + } + + private fun defaultTrigger(type: TpslType): Flow { + val percent = when (type) { + TpslType.TakeProfit -> userConfig.perpetualTakeProfit() + TpslType.StopLoss -> userConfig.perpetualStopLoss() + } + return percent.flatMapLatest { value -> + if (value == 0) { + flowOf(null) + } else { + combine(perpetual.filterNotNull(), leverageState.filterNotNull()) { market, _ -> + PerpetualFormatter.formatInputPrice( + provider = market.provider, + price = estimatorFor("").targetPriceFromRoe(value, type), + decimals = market.asset.decimals, + ) + } + } + } + } + override val minimumValue: StateFlow = combine( perpetual.filterNotNull(), leverageState, @@ -109,8 +187,23 @@ class AmountPerpetualProvider( usdcAmount = amount.atomicValue, usdcDecimals = current.asset.decimals, leverage = leverageState.value?.current?.toUByte() ?: params.positionAction.data.leverage, + takeProfit = formatTriggerForOrder(takeProfit.value, perpetualMarket), + stopLoss = formatTriggerForOrder(stopLoss.value, perpetualMarket), ) return ConfirmParams.Builder(perpetualMarket.asset, owner, amount.atomicValue, isMax) .perpetual(perpetualType) } + + private fun formatTriggerForOrder( + text: String?, + data: PerpetualDetailsDataAggregate, + ): String? { + if (!showsAutoclose) return null + val price = text?.let { numericFormatter.double(it) } ?: return null + return PerpetualFormatter.formatPrice( + provider = data.provider, + price = price, + decimals = data.asset.decimals, + ) + } } diff --git a/android/features/transfer_amount/viewmodels/src/test/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProviderTest.kt b/android/features/transfer_amount/viewmodels/src/test/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProviderTest.kt index c293e0549c..d6ba5f6855 100644 --- a/android/features/transfer_amount/viewmodels/src/test/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProviderTest.kt +++ b/android/features/transfer_amount/viewmodels/src/test/kotlin/com/gemwallet/android/features/transfer_amount/viewmodels/providers/AmountPerpetualProviderTest.kt @@ -17,20 +17,21 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test +import java.math.BigInteger +@OptIn(ExperimentalCoroutinesApi::class) class AmountPerpetualProviderTest { - @Test - fun `setLeverage updates the leverage flow`() { - val provider = makeProvider() - provider.setLeverage(10) - assertEquals(10, provider.leverageState.value?.current) - } - @Test fun `title carries the direction`() { val provider = makeProvider(direction = PerpetualDirection.Short) @@ -39,33 +40,59 @@ class AmountPerpetualProviderTest { assertEquals(PerpetualDirection.Short, open.data.direction) } - private fun makeProvider(direction: PerpetualDirection = PerpetualDirection.Long): AmountPerpetualProvider { + @Test + fun `editing then clearing a trigger keeps it cleared when no default is set`() = runTest { + val provider = makeProvider(scope = backgroundScope) + provider.setTakeProfit("65000") + provider.setStopLoss("55000") + provider.setTakeProfit("") + provider.setStopLoss(null) + advanceUntilIdle() + assertNull(provider.takeProfit.value) + assertNull(provider.stopLoss.value) + } + + @Test + fun `showsAutoclose is true for Open and false for Reduce`() { + assertTrue(makeProvider().showsAutoclose) + val reduce = makeProvider(positionAction = PerpetualPositionAction.Reduce( + data = mockPerpetualTransferData(direction = PerpetualDirection.Long), + available = BigInteger.TEN, + positionDirection = PerpetualDirection.Long, + )) + assertFalse(reduce.showsAutoclose) + } + + private fun makeProvider( + direction: PerpetualDirection = PerpetualDirection.Long, + positionAction: PerpetualPositionAction = PerpetualPositionAction.Open( + mockPerpetualTransferData(direction = direction), + ), + scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()), + ): AmountPerpetualProvider { val asset = mockAssetCosmos() val getAssetInfo = mockk(relaxed = true) { every { this@mockk.invoke(any()) } returns flowOf(null) } val userConfig = mockk(relaxed = true) { every { perpetualLeverage() } returns flowOf(5) + every { perpetualTakeProfit() } returns flowOf(0) + every { perpetualStopLoss() } returns flowOf(0) } - val perpetualAggregate = mockk(relaxed = true) { - every { maxLeverage } returns 50 - } + val perpetualAggregate = mockk(relaxed = true) val getPerpetual = mockk(relaxed = true) { every { getPerpetual(any()) } returns flowOf(perpetualAggregate) } val getPerpetualBalance = mockk(relaxed = true) { every { getBalance() } returns flowOf(null) } - val positionAction = PerpetualPositionAction.Open( - mockPerpetualTransferData(direction = direction), - ) return AmountPerpetualProvider( params = AmountParams.Perpetual(asset.id, PerpetualId(PerpetualProvider.Hypercore, "BTC-PERP"), positionAction), userConfig = userConfig, getAssetInfo = getAssetInfo, getPerpetual = getPerpetual, getPerpetualBalance = getPerpetualBalance, - scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()), + scope = scope, ) } } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/perpetual/coordinators/BuildPerpetualParams.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/perpetual/coordinators/BuildPerpetualParams.kt index 5a07c171ad..4167fcf506 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/perpetual/coordinators/BuildPerpetualParams.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/perpetual/coordinators/BuildPerpetualParams.kt @@ -4,10 +4,17 @@ import com.gemwallet.android.model.AmountParams import com.gemwallet.android.model.ConfirmParams import com.wallet.core.primitives.PerpetualDirection import com.wallet.core.primitives.PerpetualId +import com.wallet.core.primitives.PerpetualModifyPositionType interface BuildPerpetualParams { suspend fun open(perpetualId: PerpetualId, direction: PerpetualDirection): AmountParams.Perpetual? suspend fun increase(perpetualId: PerpetualId): AmountParams.Perpetual? suspend fun reduce(perpetualId: PerpetualId): AmountParams.Perpetual? suspend fun close(perpetualId: PerpetualId): ConfirmParams.PerpetualParams? + suspend fun modify( + perpetualId: PerpetualId, + modifyTypes: List, + takeProfitOrderId: ULong?, + stopLossOrderId: ULong?, + ): ConfirmParams.PerpetualParams? } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualConfig.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualConfig.kt index 48fc57762a..43068b696b 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualConfig.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualConfig.kt @@ -7,7 +7,18 @@ object PerpetualConfig { val defaultLeverage: Int get() = config.defaultLeverage.toInt() - val leverageOptions: List get() = config.leverageOptions.map { it.toInt() } + val leverageOptions: List get() = config.leverageOptions.toUnsignedInts() + + val takeProfitOptions: List get() = config.takeProfitPercentOptions.toUnsignedInts() + + val stopLossOptions: List get() = config.stopLossPercentOptions.toUnsignedInts() + + val defaultTakeProfit: Int get() = config.defaultTakeProfitPercent.toInt() + + val defaultStopLoss: Int get() = config.defaultStopLossPercent.toInt() + + fun autocloseSuggestions(leverage: Int): List = + Config().getAutocloseSuggestions(leverage.toUByte()).toUnsignedInts() fun selectLeverage(desired: Int, from: List): Int = Config().selectLeverage( @@ -15,3 +26,5 @@ object PerpetualConfig { from.map { it.toByte() }.toByteArray(), ).toInt() } + +private fun ByteArray.toUnsignedInts(): List = map { it.toUByte().toInt() } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualMappers.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualMappers.kt index 0aac56c809..0fecc223d5 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualMappers.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualMappers.kt @@ -1,16 +1,24 @@ package com.gemwallet.android.domains.perpetual import com.gemwallet.android.domains.asset.toGem +import com.wallet.core.primitives.CancelOrderData import com.wallet.core.primitives.PerpetualConfirmData import com.wallet.core.primitives.PerpetualDirection import com.wallet.core.primitives.PerpetualMarginType +import com.wallet.core.primitives.PerpetualModifyConfirmData +import com.wallet.core.primitives.PerpetualModifyPositionType import com.wallet.core.primitives.PerpetualReduceData import com.wallet.core.primitives.PerpetualType +import com.wallet.core.primitives.TPSLOrderData import uniffi.gemstone.GemPerpetualMarginType +import uniffi.gemstone.CancelOrderData as GemCancelOrderData import uniffi.gemstone.PerpetualConfirmData as GemPerpetualConfirmData import uniffi.gemstone.PerpetualDirection as GemPerpetualDirection +import uniffi.gemstone.PerpetualModifyConfirmData as GemPerpetualModifyConfirmData +import uniffi.gemstone.PerpetualModifyPositionType as GemPerpetualModifyPositionType import uniffi.gemstone.PerpetualReduceData as GemPerpetualReduceData import uniffi.gemstone.PerpetualType as GemPerpetualType +import uniffi.gemstone.TpslOrderData as GemTpslOrderData fun PerpetualConfirmData.toGem(): GemPerpetualConfirmData = GemPerpetualConfirmData( direction = direction.toGem(), @@ -35,6 +43,31 @@ fun PerpetualReduceData.toGem(): GemPerpetualReduceData = GemPerpetualReduceData positionDirection = positionDirection.toGem(), ) +fun PerpetualModifyConfirmData.toGem(): GemPerpetualModifyConfirmData = GemPerpetualModifyConfirmData( + baseAsset = baseAsset.toGem(), + assetIndex = assetIndex, + modifyTypes = modifyTypes.map { it.toGem() }, + takeProfitOrderId = takeProfitOrderId?.toULong(), + stopLossOrderId = stopLossOrderId?.toULong(), +) + +fun PerpetualModifyPositionType.toGem(): GemPerpetualModifyPositionType = when (this) { + is PerpetualModifyPositionType.Tpsl -> GemPerpetualModifyPositionType.Tpsl(v1 = content.toGem()) + is PerpetualModifyPositionType.Cancel -> GemPerpetualModifyPositionType.Cancel(v1 = content.map { it.toGem() }) +} + +fun TPSLOrderData.toGem(): GemTpslOrderData = GemTpslOrderData( + direction = direction.toGem(), + takeProfit = takeProfit, + stopLoss = stopLoss, + size = size, +) + +fun CancelOrderData.toGem(): GemCancelOrderData = GemCancelOrderData( + assetIndex = assetIndex, + orderId = orderId.toULong(), +) + fun PerpetualDirection.toGem(): GemPerpetualDirection = when (this) { PerpetualDirection.Long -> GemPerpetualDirection.LONG PerpetualDirection.Short -> GemPerpetualDirection.SHORT @@ -50,5 +83,5 @@ fun PerpetualType.toGem(): GemPerpetualType = when (this) { is PerpetualType.Close -> GemPerpetualType.Close(v1 = content.toGem()) is PerpetualType.Increase -> GemPerpetualType.Increase(v1 = content.toGem()) is PerpetualType.Reduce -> GemPerpetualType.Reduce(v1 = content.toGem()) - is PerpetualType.Modify -> error("PerpetualType.Modify not produced by Android app") + is PerpetualType.Modify -> GemPerpetualType.Modify(v1 = content.toGem()) } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseError.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseError.kt new file mode 100644 index 0000000000..3bbe75d7b9 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseError.kt @@ -0,0 +1,7 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +enum class AutocloseError { + InvalidAmount, + TriggerMustBeHigher, + TriggerMustBeLower, +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseEstimator.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseEstimator.kt new file mode 100644 index 0000000000..d98c2f0dcc --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseEstimator.kt @@ -0,0 +1,51 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +import com.gemwallet.android.domains.perpetual.PerpetualConfig +import com.gemwallet.android.domains.price.PriceChange +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.TpslType +import kotlin.math.abs + +class AutocloseEstimator( + val entryPrice: Double, + val positionSize: Double, + val direction: PerpetualDirection, + val leverage: UByte, +) { + val hasSize: Boolean get() = positionSize != 0.0 + + val percentSuggestions: List + get() = PerpetualConfig.autocloseSuggestions(leverage.toInt()) + + fun pnl(price: Double): Double { + val absSize = abs(positionSize) + val delta = price - entryPrice + return when (direction) { + PerpetualDirection.Long -> delta * absSize + PerpetualDirection.Short -> -delta * absSize + } + } + + private fun priceChangePercent(price: Double): Double { + val raw = PriceChange.percentage(from = entryPrice, to = price) + return if (direction == PerpetualDirection.Short) -raw else raw + } + + fun roe(price: Double): Double = priceChangePercent(price) * leverage.toInt() + + fun targetPriceFromRoe(roePercent: Int, type: TpslType): Double { + val leverageInt = leverage.toInt().coerceAtLeast(1) + val fraction = roePercent.toDouble() / leverageInt.toDouble() / 100.0 + val sign = when (type) { + TpslType.TakeProfit -> when (direction) { + PerpetualDirection.Long -> 1.0 + PerpetualDirection.Short -> -1.0 + } + TpslType.StopLoss -> when (direction) { + PerpetualDirection.Long -> -1.0 + PerpetualDirection.Short -> 1.0 + } + } + return entryPrice * (1.0 + sign * fraction) + } +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseField.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseField.kt new file mode 100644 index 0000000000..1eb3d4c0c5 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseField.kt @@ -0,0 +1,21 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +import com.wallet.core.primitives.TpslType + +data class AutocloseField( + val type: TpslType, + val price: Double?, + val originalPrice: Double?, + val formattedPrice: String?, + val error: AutocloseError?, + val orderId: ULong?, +) { + val isValid: Boolean get() = price != null && error == null + val hasChanged: Boolean get() = price != originalPrice + val isCleared: Boolean get() = price == null && originalPrice != null + val hasExisting: Boolean get() = originalPrice != null + val shouldSet: Boolean get() = isValid && hasChanged + val shouldUpdate: Boolean get() = shouldSet || isCleared + val shouldCancel: Boolean get() = isCleared || (shouldSet && hasExisting) + val hasPendingChange: Boolean get() = isCleared || (price != null && hasChanged) +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseModifyBuilder.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseModifyBuilder.kt new file mode 100644 index 0000000000..9f059cf070 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseModifyBuilder.kt @@ -0,0 +1,46 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +import com.wallet.core.primitives.CancelOrderData +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.PerpetualModifyPositionType +import com.wallet.core.primitives.TPSLOrderData + +class AutocloseModifyBuilder(private val direction: PerpetualDirection) { + + fun canBuild(takeProfit: AutocloseField, stopLoss: AutocloseField): Boolean { + val takeProfitOk = takeProfit.price == null || takeProfit.isValid + val stopLossOk = stopLoss.price == null || stopLoss.isValid + if (!takeProfitOk || !stopLossOk) return false + return takeProfit.shouldUpdate || stopLoss.shouldUpdate + } + + fun build( + assetIndex: Int, + takeProfit: AutocloseField, + stopLoss: AutocloseField, + ): List { + val result = mutableListOf() + + val cancels = listOf(takeProfit, stopLoss).mapNotNull { field -> + if (!field.shouldCancel) return@mapNotNull null + val orderId = field.orderId ?: return@mapNotNull null + CancelOrderData(assetIndex = assetIndex, orderId = orderId.toLong()) + } + if (cancels.isNotEmpty()) { + result += PerpetualModifyPositionType.Cancel(cancels) + } + + if (takeProfit.shouldSet || stopLoss.shouldSet) { + result += PerpetualModifyPositionType.Tpsl( + TPSLOrderData( + direction = direction, + takeProfit = takeProfit.formattedPrice?.takeIf { takeProfit.shouldSet }, + stopLoss = stopLoss.formattedPrice?.takeIf { stopLoss.shouldSet }, + size = "0", + ), + ) + } + + return result + } +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseValidator.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseValidator.kt new file mode 100644 index 0000000000..efb519a566 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseValidator.kt @@ -0,0 +1,22 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.TpslType + +class AutocloseValidator( + private val type: TpslType, + private val direction: PerpetualDirection, + private val marketPrice: Double, +) { + fun error(price: Double?): AutocloseError? { + if (price == null) return null + if (price <= 0.0) return AutocloseError.InvalidAmount + val mustBeAbove = when (type) { + TpslType.TakeProfit -> direction == PerpetualDirection.Long + TpslType.StopLoss -> direction == PerpetualDirection.Short + } + val onCorrectSide = if (mustBeAbove) price > marketPrice else price < marketPrice + if (onCorrectSide) return null + return if (mustBeAbove) AutocloseError.TriggerMustBeHigher else AutocloseError.TriggerMustBeLower + } +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/PerpetualFormatter.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/PerpetualFormatter.kt index fc517b87e8..0f69439080 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/PerpetualFormatter.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/PerpetualFormatter.kt @@ -2,6 +2,8 @@ package com.gemwallet.android.ext import com.wallet.core.primitives.PerpetualProvider import uniffi.gemstone.Perpetual +import java.text.DecimalFormatSymbols +import java.util.Locale import uniffi.gemstone.PerpetualProvider as GemPerpetualProvider object PerpetualFormatter { @@ -9,6 +11,17 @@ object PerpetualFormatter { fun formatPrice(provider: PerpetualProvider, price: Double, decimals: Int): String = Perpetual(provider.toGem()).use { it.formatPrice(price, decimals) } + fun formatInputPrice( + provider: PerpetualProvider, + price: Double, + decimals: Int, + locale: Locale = Locale.getDefault(), + ): String { + val formatted = formatPrice(provider, price, decimals) + val separator = DecimalFormatSymbols.getInstance(locale).decimalSeparator + return if (separator == '.') formatted else formatted.replace('.', separator) + } + fun formatSize(provider: PerpetualProvider, size: Double, decimals: Int): String = Perpetual(provider.toGem()).use { it.formatSize(size, decimals) } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/NumericFormatter.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/NumericFormatter.kt index a09ec96cb8..48cb440091 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/NumericFormatter.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/NumericFormatter.kt @@ -4,6 +4,7 @@ import java.math.BigDecimal import java.math.RoundingMode import java.text.DecimalFormat import java.text.NumberFormat +import java.text.ParseException import java.util.Locale class NumericFormatter( @@ -19,6 +20,16 @@ class NumericFormatter( return if (symbol == null) number else "$number $symbol" } + fun double(from: String): Double? { + val text = from.trim() + if (text.isEmpty()) return null + return try { + newFormatter().parse(text)?.toDouble()?.takeIf(Double::isFinite) + } catch (_: ParseException) { + null + } + } + private fun newFormatter(): DecimalFormat = (NumberFormat.getNumberInstance(locale) as DecimalFormat).apply { roundingMode = RoundingMode.HALF_EVEN diff --git a/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseEstimatorTest.kt b/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseEstimatorTest.kt new file mode 100644 index 0000000000..627da534fa --- /dev/null +++ b/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseEstimatorTest.kt @@ -0,0 +1,41 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.TpslType +import org.junit.Assert.assertEquals +import org.junit.Test + +class AutocloseEstimatorTest { + + @Test + fun pnlLong() { + val estimator = AutocloseEstimator(entryPrice = 100.0, positionSize = 10.0, direction = PerpetualDirection.Long, leverage = 5u) + assertEquals(100.0, estimator.pnl(price = 110.0), DELTA) + assertEquals(-100.0, estimator.pnl(price = 90.0), DELTA) + } + + @Test + fun pnlShort() { + val estimator = AutocloseEstimator(entryPrice = 100.0, positionSize = -10.0, direction = PerpetualDirection.Short, leverage = 5u) + assertEquals(100.0, estimator.pnl(price = 90.0), DELTA) + assertEquals(-100.0, estimator.pnl(price = 110.0), DELTA) + } + + @Test + fun targetPriceFromRoeLong() { + val estimator = AutocloseEstimator(entryPrice = 100.0, positionSize = 10.0, direction = PerpetualDirection.Long, leverage = 5u) + assertEquals(110.0, estimator.targetPriceFromRoe(roePercent = 50, type = TpslType.TakeProfit), DELTA) + assertEquals(90.0, estimator.targetPriceFromRoe(roePercent = 50, type = TpslType.StopLoss), DELTA) + } + + @Test + fun targetPriceFromRoeShort() { + val estimator = AutocloseEstimator(entryPrice = 100.0, positionSize = -10.0, direction = PerpetualDirection.Short, leverage = 5u) + assertEquals(90.0, estimator.targetPriceFromRoe(roePercent = 50, type = TpslType.TakeProfit), DELTA) + assertEquals(110.0, estimator.targetPriceFromRoe(roePercent = 50, type = TpslType.StopLoss), DELTA) + } + + private companion object { + const val DELTA = 1e-9 + } +} diff --git a/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseModifyBuilderTest.kt b/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseModifyBuilderTest.kt new file mode 100644 index 0000000000..49c4b4c985 --- /dev/null +++ b/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseModifyBuilderTest.kt @@ -0,0 +1,79 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +import com.gemwallet.android.testkit.mockAutocloseField +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.PerpetualModifyPositionType +import com.wallet.core.primitives.TpslType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AutocloseModifyBuilderTest { + + private val builder = AutocloseModifyBuilder(direction = PerpetualDirection.Long) + + @Test + fun canBuildWithValidChange() { + val takeProfit = mockAutocloseField(TpslType.TakeProfit, price = 110.0, originalPrice = 100.0, error = null) + val stopLoss = mockAutocloseField(TpslType.StopLoss) + assertTrue(builder.canBuild(takeProfit, stopLoss)) + } + + @Test + fun canBuildClearingExistingField() { + val takeProfit = mockAutocloseField(TpslType.TakeProfit, price = null, originalPrice = 100.0) + val stopLoss = mockAutocloseField(TpslType.StopLoss) + assertTrue(builder.canBuild(takeProfit, stopLoss)) + } + + @Test + fun cannotBuildWithoutChanges() { + val takeProfit = mockAutocloseField(TpslType.TakeProfit, price = 100.0, originalPrice = 100.0, error = null) + val stopLoss = mockAutocloseField(TpslType.StopLoss, price = 90.0, originalPrice = 90.0, error = null) + assertFalse(builder.canBuild(takeProfit, stopLoss)) + } + + @Test + fun cannotBuildWithInvalidPrice() { + val takeProfit = mockAutocloseField(TpslType.TakeProfit, price = 50.0, originalPrice = 100.0, error = AutocloseError.TriggerMustBeHigher) + val stopLoss = mockAutocloseField(TpslType.StopLoss) + assertFalse(builder.canBuild(takeProfit, stopLoss)) + } + + @Test + fun buildSetBothEmitsSingleTpslWithSizeZero() { + val takeProfit = mockAutocloseField(TpslType.TakeProfit, price = 110.0, formattedPrice = "110.0", error = null) + val stopLoss = mockAutocloseField(TpslType.StopLoss, price = 90.0, formattedPrice = "90.0", error = null) + + val result = builder.build(assetIndex = 5, takeProfit = takeProfit, stopLoss = stopLoss) + + val tpsl = result.single() as PerpetualModifyPositionType.Tpsl + assertEquals("110.0", tpsl.content.takeProfit) + assertEquals("90.0", tpsl.content.stopLoss) + assertEquals("0", tpsl.content.size) + assertEquals(PerpetualDirection.Long, tpsl.content.direction) + } + + @Test + fun buildReplaceEmitsCancelBeforeTpsl() { + val takeProfit = mockAutocloseField( + TpslType.TakeProfit, + price = 120.0, + formattedPrice = "120.0", + originalPrice = 100.0, + error = null, + orderId = 12345uL, + ) + val stopLoss = mockAutocloseField(TpslType.StopLoss) + + val result = builder.build(assetIndex = 5, takeProfit = takeProfit, stopLoss = stopLoss) + + assertEquals(2, result.size) + val cancel = result[0] as PerpetualModifyPositionType.Cancel + assertEquals(12345L, cancel.content[0].orderId) + assertEquals(5, cancel.content[0].assetIndex) + val tpsl = result[1] as PerpetualModifyPositionType.Tpsl + assertEquals("120.0", tpsl.content.takeProfit) + } +} diff --git a/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseValidatorTest.kt b/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseValidatorTest.kt new file mode 100644 index 0000000000..00c507f578 --- /dev/null +++ b/android/gemcore/src/test/kotlin/com/gemwallet/android/domains/perpetual/autoclose/AutocloseValidatorTest.kt @@ -0,0 +1,46 @@ +package com.gemwallet.android.domains.perpetual.autoclose + +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.TpslType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AutocloseValidatorTest { + + @Test + fun nullAndZeroPrice() { + val validator = AutocloseValidator(TpslType.TakeProfit, PerpetualDirection.Long, marketPrice = 100.0) + assertNull(validator.error(price = null)) + assertEquals(AutocloseError.InvalidAmount, validator.error(price = 0.0)) + assertEquals(AutocloseError.InvalidAmount, validator.error(price = -1.0)) + } + + @Test + fun longTakeProfitMustBeAboveMarket() { + val validator = AutocloseValidator(TpslType.TakeProfit, PerpetualDirection.Long, marketPrice = 100.0) + assertNull(validator.error(price = 110.0)) + assertEquals(AutocloseError.TriggerMustBeHigher, validator.error(price = 90.0)) + } + + @Test + fun longStopLossMustBeBelowMarket() { + val validator = AutocloseValidator(TpslType.StopLoss, PerpetualDirection.Long, marketPrice = 100.0) + assertNull(validator.error(price = 90.0)) + assertEquals(AutocloseError.TriggerMustBeLower, validator.error(price = 110.0)) + } + + @Test + fun shortTakeProfitMustBeBelowMarket() { + val validator = AutocloseValidator(TpslType.TakeProfit, PerpetualDirection.Short, marketPrice = 100.0) + assertNull(validator.error(price = 90.0)) + assertEquals(AutocloseError.TriggerMustBeLower, validator.error(price = 110.0)) + } + + @Test + fun shortStopLossMustBeAboveMarket() { + val validator = AutocloseValidator(TpslType.StopLoss, PerpetualDirection.Short, marketPrice = 100.0) + assertNull(validator.error(price = 110.0)) + assertEquals(AutocloseError.TriggerMustBeHigher, validator.error(price = 90.0)) + } +} diff --git a/android/gemcore/src/test/kotlin/com/gemwallet/android/model/NumericFormatterTest.kt b/android/gemcore/src/test/kotlin/com/gemwallet/android/model/NumericFormatterTest.kt index df1a217f48..07e02bc972 100644 --- a/android/gemcore/src/test/kotlin/com/gemwallet/android/model/NumericFormatterTest.kt +++ b/android/gemcore/src/test/kotlin/com/gemwallet/android/model/NumericFormatterTest.kt @@ -1,6 +1,7 @@ package com.gemwallet.android.model import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test import java.util.Locale @@ -36,4 +37,17 @@ class NumericFormatterTest { assertEquals("29,73", de.string(29.73)) assertEquals("12.000.123,00", de.string(12_000_123.0)) } + + @Test + fun parseDouble() { + assertEquals(11.12, us.double("11.12")!!, 1e-9) + assertEquals(11.0, us.double("11")!!, 1e-9) + assertNull(us.double("")) + assertNull(us.double(" ")) + assertNull(us.double("abc")) + assertEquals(29.73, de.double("29,73")!!, 1e-9) + assertEquals(12_000.5, us.double("12,000.5")!!, 1e-9) + assertEquals(12_000.5, de.double("12.000,5")!!, 1e-9) + assertEquals(12.0, us.double("12abc")!!, 1e-9) + } } diff --git a/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualConfirmDataMock.kt b/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualConfirmDataMock.kt index 12cc0a7091..8b26073092 100644 --- a/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualConfirmDataMock.kt +++ b/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualConfirmDataMock.kt @@ -2,11 +2,15 @@ package com.gemwallet.android.testkit import com.gemwallet.android.domains.perpetual.PerpetualTransferData import com.wallet.core.primitives.Asset +import com.wallet.core.primitives.CancelOrderData import com.wallet.core.primitives.PerpetualConfirmData import com.wallet.core.primitives.PerpetualDirection import com.wallet.core.primitives.PerpetualMarginType +import com.wallet.core.primitives.PerpetualModifyConfirmData +import com.wallet.core.primitives.PerpetualModifyPositionType import com.wallet.core.primitives.PerpetualProvider import com.wallet.core.primitives.PerpetualReduceData +import com.wallet.core.primitives.TPSLOrderData fun mockPerpetualConfirmData( direction: PerpetualDirection = PerpetualDirection.Long, @@ -50,6 +54,38 @@ fun mockPerpetualReduceData( positionDirection = positionDirection, ) +fun mockPerpetualModifyConfirmData( + modifyTypes: List = emptyList(), + baseAsset: Asset = mockAssetHyperCoreUSDC(), + assetIndex: Int = 0, + takeProfitOrderId: Long? = null, + stopLossOrderId: Long? = null, +) = PerpetualModifyConfirmData( + baseAsset = baseAsset, + assetIndex = assetIndex, + modifyTypes = modifyTypes, + takeProfitOrderId = takeProfitOrderId, + stopLossOrderId = stopLossOrderId, +) + +fun mockTpslOrder( + direction: PerpetualDirection = PerpetualDirection.Long, + takeProfit: String? = null, + stopLoss: String? = null, + size: String = "1.0", +) = PerpetualModifyPositionType.Tpsl( + TPSLOrderData( + direction = direction, + takeProfit = takeProfit, + stopLoss = stopLoss, + size = size, + ), +) + +fun mockCancel(orderIds: List, assetIndex: Int = 0) = PerpetualModifyPositionType.Cancel( + orderIds.map { CancelOrderData(assetIndex = assetIndex, orderId = it) }, +) + fun mockPerpetualTransferData( provider: PerpetualProvider = PerpetualProvider.Hypercore, direction: PerpetualDirection = PerpetualDirection.Long, diff --git a/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualMock.kt b/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualMock.kt index 5aa24e0af4..e8a9a2d24b 100644 --- a/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualMock.kt +++ b/android/gemcore/src/testFixtures/kotlin/com/gemwallet/android/testkit/PerpetualMock.kt @@ -1,12 +1,20 @@ package com.gemwallet.android.testkit +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseError +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseField import com.wallet.core.primitives.Asset import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.Perpetual import com.wallet.core.primitives.PerpetualData +import com.wallet.core.primitives.PerpetualDirection import com.wallet.core.primitives.PerpetualId +import com.wallet.core.primitives.PerpetualMarginType import com.wallet.core.primitives.PerpetualMetadata +import com.wallet.core.primitives.PerpetualPosition +import com.wallet.core.primitives.PerpetualPositionData import com.wallet.core.primitives.PerpetualProvider +import com.wallet.core.primitives.PerpetualTriggerOrder +import com.wallet.core.primitives.TpslType fun mockPerpetual( id: PerpetualId = PerpetualId(provider = PerpetualProvider.Hypercore, symbol = "TON"), @@ -45,3 +53,63 @@ fun mockPerpetualData( asset = asset, metadata = metadata, ) + +fun mockPerpetualPosition( + id: String = "pos-1", + perpetualId: PerpetualId = PerpetualId(provider = PerpetualProvider.Hypercore, symbol = "TON"), + assetId: AssetId = mockAsset().id, + size: Double = 10.0, + sizeValue: Double = 1000.0, + leverage: UByte = 5u, + entryPrice: Double = 100.0, + liquidationPrice: Double? = 50.0, + marginType: PerpetualMarginType = PerpetualMarginType.Cross, + direction: PerpetualDirection = PerpetualDirection.Long, + marginAmount: Double = 200.0, + takeProfit: PerpetualTriggerOrder? = null, + stopLoss: PerpetualTriggerOrder? = null, + pnl: Double = 0.0, + funding: Float? = null, +) = PerpetualPosition( + id = id, + perpetualId = perpetualId, + assetId = assetId, + size = size, + sizeValue = sizeValue, + leverage = leverage, + entryPrice = entryPrice, + liquidationPrice = liquidationPrice, + marginType = marginType, + direction = direction, + marginAmount = marginAmount, + takeProfit = takeProfit, + stopLoss = stopLoss, + pnl = pnl, + funding = funding, +) + +fun mockPerpetualPositionData( + perpetual: Perpetual = mockPerpetual(price = 100.0), + asset: Asset = mockAsset(), + position: PerpetualPosition = mockPerpetualPosition(assetId = asset.id, perpetualId = perpetual.id), +) = PerpetualPositionData( + perpetual = perpetual, + asset = asset, + position = position, +) + +fun mockAutocloseField( + type: TpslType = TpslType.TakeProfit, + price: Double? = null, + originalPrice: Double? = null, + formattedPrice: String? = price?.toString(), + error: AutocloseError? = null, + orderId: ULong? = null, +) = AutocloseField( + type = type, + price = price, + originalPrice = originalPrice, + formattedPrice = formattedPrice, + error = error, + orderId = orderId, +) diff --git a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModel.kt b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModel.kt new file mode 100644 index 0000000000..96c918a774 --- /dev/null +++ b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModel.kt @@ -0,0 +1,26 @@ +package com.gemwallet.android.ui.models.perpetual.autoclose + +import com.gemwallet.android.domains.perpetual.aggregates.PerpetualPositionDataAggregate +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseError +import com.gemwallet.android.domains.price.ValueDirection +import com.wallet.core.primitives.TpslType + +data class AutocloseUIModel( + val position: PerpetualPositionDataAggregate, + val marketPriceText: String, + val entryPriceText: String, + val takeProfit: Field, + val stopLoss: Field, + val confirmEnabled: Boolean, +) { + data class Field( + val type: TpslType, + val isProfit: Boolean, + val pnlText: String, + val pnlDirection: ValueDirection, + val percentSuggestions: List, + val error: AutocloseError?, + ) { + val showError: Boolean get() = error != null + } +} diff --git a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactory.kt b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactory.kt new file mode 100644 index 0000000000..1a66a8b6a1 --- /dev/null +++ b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactory.kt @@ -0,0 +1,88 @@ +package com.gemwallet.android.ui.models.perpetual.autoclose + +import com.gemwallet.android.domains.percentage.PercentageFormatterStyle +import com.gemwallet.android.domains.percentage.formatAsPercentage +import com.gemwallet.android.domains.perpetual.aggregates.PerpetualPositionDataAggregate +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseEstimator +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseField +import com.gemwallet.android.domains.perpetual.formatPnlWithPercentage +import com.gemwallet.android.domains.price.ValueDirection +import com.gemwallet.android.domains.price.toValueDirection +import com.gemwallet.android.model.CurrencyFormatter +import com.wallet.core.primitives.Asset +import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.PerpetualId +import com.wallet.core.primitives.PerpetualPositionData +import com.wallet.core.primitives.TpslType +import kotlin.math.abs + +object AutocloseUIModelFactory { + + private val currencyFormatter = CurrencyFormatter(type = CurrencyFormatter.Type.Currency, currency = Currency.USD) + private val marginFormatter = CurrencyFormatter(type = CurrencyFormatter.Type.Fiat, currency = Currency.USD) + + fun create( + position: PerpetualPositionData, + takeProfit: AutocloseField, + stopLoss: AutocloseField, + confirmEnabled: Boolean, + showErrors: Boolean = false, + ): AutocloseUIModel { + val estimator = AutocloseEstimator( + entryPrice = position.position.entryPrice, + positionSize = position.position.size, + direction = position.position.direction, + leverage = position.position.leverage, + ) + return AutocloseUIModel( + position = positionSummary(position), + marketPriceText = currencyFormatter.string(position.perpetual.price), + entryPriceText = currencyFormatter.string(position.position.entryPrice), + takeProfit = createField(takeProfit, estimator, showErrors), + stopLoss = createField(stopLoss, estimator, showErrors), + confirmEnabled = confirmEnabled, + ) + } + + fun createField( + field: AutocloseField, + estimator: AutocloseEstimator, + showErrors: Boolean = true, + ): AutocloseUIModel.Field { + val priceForEstimation = field.price.takeIf { field.error == null } + val pnl = priceForEstimation?.let(estimator::pnl) + val roe = priceForEstimation?.let(estimator::roe) + val isProfit = pnl?.let { it >= 0.0 } ?: (field.type == TpslType.TakeProfit) + return AutocloseUIModel.Field( + type = field.type, + isProfit = isProfit, + pnlText = pnlText(pnl, roe, estimator.hasSize), + pnlDirection = roe?.toValueDirection() ?: ValueDirection.None, + percentSuggestions = estimator.percentSuggestions, + error = if (showErrors) field.error else null, + ) + } + + private fun positionSummary(data: PerpetualPositionData): PerpetualPositionDataAggregate = object : PerpetualPositionDataAggregate { + override val positionId: String = data.position.id + override val perpetualId: PerpetualId = data.perpetual.id + override val asset: Asset = data.asset + override val name: String = data.perpetual.name + override val direction: PerpetualDirection = data.position.direction + override val leverage: Int = data.position.leverage.toInt() + override val marginAmount: String = marginFormatter.string(data.position.marginAmount) + override val pnlWithPercentage: String = + formatPnlWithPercentage(data.position.pnl, data.position.marginAmount) + override val pnlState: ValueDirection = data.position.pnl.toValueDirection() + } + + private fun pnlText(pnl: Double?, roe: Double?, hasSize: Boolean): String { + if (pnl == null || roe == null) return "-" + val percentText = roe.formatAsPercentage(style = PercentageFormatterStyle.Percent) + if (!hasSize) return percentText + val sign = if (pnl >= 0.0) "+" else "-" + val amount = currencyFormatter.string(abs(pnl)) + return "$sign$amount ($percentText)" + } +} diff --git a/android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactoryTest.kt b/android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactoryTest.kt new file mode 100644 index 0000000000..a80b84d2ce --- /dev/null +++ b/android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/perpetual/autoclose/AutocloseUIModelFactoryTest.kt @@ -0,0 +1,68 @@ +package com.gemwallet.android.ui.models.perpetual.autoclose + +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseError +import com.gemwallet.android.testkit.mockAutocloseField +import com.gemwallet.android.testkit.mockPerpetualPosition +import com.gemwallet.android.testkit.mockPerpetualPositionData +import com.wallet.core.primitives.PerpetualDirection +import com.wallet.core.primitives.TpslType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class AutocloseUIModelFactoryTest { + + @Test + fun percentSuggestionsScaleWithLeverage() { + assertEquals(listOf(5, 10, 15), model(leverage = 1u).takeProfit.percentSuggestions) + assertEquals(listOf(10, 15, 25), model(leverage = 5u).takeProfit.percentSuggestions) + assertEquals(listOf(15, 25, 50), model(leverage = 10u).takeProfit.percentSuggestions) + assertEquals(listOf(25, 50, 100), model(leverage = 20u).takeProfit.percentSuggestions) + } + + @Test + fun pnlSuppressedWhenFieldHasError() { + val invalid = mockAutocloseField(TpslType.TakeProfit, price = 50.0, error = AutocloseError.TriggerMustBeHigher) + val model = AutocloseUIModelFactory.create( + position = mockPerpetualPositionData( + position = mockPerpetualPosition(direction = PerpetualDirection.Long, entryPrice = 100.0, leverage = 5u), + ), + takeProfit = invalid, + stopLoss = mockAutocloseField(TpslType.StopLoss), + confirmEnabled = false, + ) + assertEquals("-", model.takeProfit.pnlText) + } + + @Test + fun errorSuppressedUntilShowErrorsSet() { + val invalidTakeProfit = mockAutocloseField(TpslType.TakeProfit, price = 50.0, error = AutocloseError.TriggerMustBeHigher) + val hidden = AutocloseUIModelFactory.create( + position = mockPerpetualPositionData( + position = mockPerpetualPosition(direction = PerpetualDirection.Long, entryPrice = 100.0, leverage = 5u), + ), + takeProfit = invalidTakeProfit, + stopLoss = mockAutocloseField(TpslType.StopLoss), + confirmEnabled = false, + showErrors = false, + ) + val shown = AutocloseUIModelFactory.create( + position = mockPerpetualPositionData( + position = mockPerpetualPosition(direction = PerpetualDirection.Long, entryPrice = 100.0, leverage = 5u), + ), + takeProfit = invalidTakeProfit, + stopLoss = mockAutocloseField(TpslType.StopLoss), + confirmEnabled = false, + showErrors = true, + ) + assertFalse(hidden.takeProfit.showError) + assertEquals(AutocloseError.TriggerMustBeHigher, shown.takeProfit.error) + } + + private fun model(leverage: UByte) = AutocloseUIModelFactory.create( + position = mockPerpetualPositionData(position = mockPerpetualPosition(leverage = leverage)), + takeProfit = mockAutocloseField(TpslType.TakeProfit), + stopLoss = mockAutocloseField(TpslType.StopLoss), + confirmEnabled = false, + ) +} diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/GemTextField.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/GemTextField.kt index 9d839f3a50..3cea462c19 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/GemTextField.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/GemTextField.kt @@ -11,14 +11,21 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.gemwallet.android.ui.components.list_item.listItem import com.gemwallet.android.ui.models.ListPosition @@ -41,8 +48,13 @@ fun GemTextField( keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, listPosition: ListPosition = ListPosition.Single, + errorDivider: Boolean = false, ) { val hasFloatingLabel = value.isNotEmpty() && label.isNotEmpty() + var textFieldValue by remember { mutableStateOf(TextFieldValue(value, TextRange(value.length))) } + if (textFieldValue.text != value) { + textFieldValue = TextFieldValue(value, TextRange(value.length)) + } Column( modifier = modifier @@ -57,8 +69,11 @@ fun GemTextField( ) { BasicTextField( modifier = Modifier.weight(1f), - value = value, - onValueChange = onValueChange, + value = textFieldValue, + onValueChange = { next -> + textFieldValue = next + if (next.text != value) onValueChange(next.text) + }, readOnly = readOnly, singleLine = singleLine, textStyle = MaterialTheme.typography.bodyLarge.copy( @@ -97,6 +112,10 @@ fun GemTextField( trailing?.invoke() } if (error.isNotEmpty()) { + if (errorDivider) { + Spacer4() + HorizontalDivider() + } Spacer4() Text( modifier = Modifier, diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/LinkItem.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/LinkItem.kt index 7ccb85ffa2..82d3c02c8a 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/LinkItem.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/LinkItem.kt @@ -3,6 +3,7 @@ package com.gemwallet.android.ui.components.list_item import androidx.annotation.DrawableRes import androidx.compose.foundation.Image import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.RowScope import androidx.compose.material3.Text @@ -41,6 +42,7 @@ fun LinkItem( title: String, painter: Painter? = null, listPosition: ListPosition = ListPosition.Middle, + indented: Boolean = false, supportingContent: (@Composable () -> Unit)? = null, trailingContent: (@Composable RowScope.() -> Unit)? = null, onLongClick: (() -> Unit)? = null, @@ -59,16 +61,20 @@ fun LinkItem( onLongClick = onLongClick, ), minHeight = minHeight, - leading = if (painter != null) { - { - Image( - modifier = Modifier.size(iconSize), - painter = painter, - contentDescription = "setting_item" - ) + leading = when { + painter != null -> { + { + Image( + modifier = Modifier.size(iconSize), + painter = painter, + contentDescription = "setting_item" + ) + } } - } else { - null + indented -> { + { Spacer(modifier = Modifier.size(iconSize)) } + } + else -> null }, title = { Text(text = title) }, subtitle = supportingContent, diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyItem.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyItem.kt index b06ae7cf03..e5fe434bc1 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyItem.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyItem.kt @@ -35,6 +35,7 @@ fun PropertyItem( @StringRes action: Int, actionIconModel: Any? = null, data: String? = null, + info: InfoSheetEntity? = null, listPosition: ListPosition = ListPosition.Middle, onClick: () -> Unit, ) { @@ -42,6 +43,7 @@ fun PropertyItem( action = stringResource(action), actionIconModel = actionIconModel, data = data, + info = info, listPosition = listPosition, onClick = onClick ) @@ -52,6 +54,7 @@ fun PropertyItem( action: String, actionIconModel: Any? = null, data: String? = null, + info: InfoSheetEntity? = null, listPosition: ListPosition = ListPosition.Middle, onClick: () -> Unit, ) { @@ -61,6 +64,7 @@ fun PropertyItem( PropertyTitleText( text = action, trailing = actionIconModel?.let { { AsyncImage(model = it, size = smallIconSize) } }, + info = info, ) }, data = { diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseInputSection.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseInputSection.kt new file mode 100644 index 0000000000..1fd53e2948 --- /dev/null +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseInputSection.kt @@ -0,0 +1,103 @@ +package com.gemwallet.android.ui.components.perpetual + +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.GemTextField +import com.gemwallet.android.ui.components.list_item.SubheaderItem +import com.gemwallet.android.ui.components.list_item.color +import com.gemwallet.android.ui.components.list_item.sectionHeaderHorizontalPadding +import com.gemwallet.android.ui.icons.AppIcons +import com.gemwallet.android.ui.models.ListPosition +import com.gemwallet.android.domains.perpetual.autoclose.AutocloseError +import com.gemwallet.android.ui.models.perpetual.autoclose.AutocloseUIModel +import com.gemwallet.android.ui.theme.compactIconSize +import com.gemwallet.android.ui.theme.space4 +import com.wallet.core.primitives.TpslType + +@StringRes +private fun AutocloseError.toStringRes(): Int = when (this) { + AutocloseError.InvalidAmount -> R.string.errors_invalid_amount + AutocloseError.TriggerMustBeHigher -> R.string.errors_perpetual_trigger_price_higher + AutocloseError.TriggerMustBeLower -> R.string.errors_perpetual_trigger_price_lower +} + +@Composable +fun AutocloseInputSection( + field: AutocloseUIModel.Field, + text: String, + onTextChanged: (String) -> Unit, + onFocusChanged: (Boolean) -> Unit, +) { + SubheaderItem( + title = stringResource( + when (field.type) { + TpslType.TakeProfit -> R.string.perpetual_auto_close_take_profit + TpslType.StopLoss -> R.string.perpetual_auto_close_stop_loss + }, + ), + ) + GemTextField( + modifier = Modifier.onFocusChanged { onFocusChanged(it.isFocused) }, + value = text, + onValueChange = onTextChanged, + label = stringResource(R.string.asset_price), + error = field.error?.let { stringResource(it.toStringRes()) }.orEmpty(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + listPosition = ListPosition.Single, + errorDivider = true, + trailing = if (text.isNotEmpty()) { + { + Icon( + modifier = Modifier + .size(compactIconSize) + .clickable( + interactionSource = null, + indication = null, + onClick = { onTextChanged("") }, + ), + imageVector = AppIcons.Cancel, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + ) + } + } else null, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = sectionHeaderHorizontalPadding, vertical = space4), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource( + if (field.isProfit) R.string.perpetual_auto_close_expected_profit + else R.string.perpetual_auto_close_expected_loss, + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.secondary, + ) + Text( + text = field.pnlText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = field.pnlDirection.color(), + ) + } +} diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseSuggestionsBar.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseSuggestionsBar.kt new file mode 100644 index 0000000000..dcd724ee6d --- /dev/null +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseSuggestionsBar.kt @@ -0,0 +1,38 @@ +package com.gemwallet.android.ui.components.perpetual + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun AutocloseSuggestionsBar( + suggestions: List, + onPercentSelected: (Int) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + suggestions.forEach { percent -> + SuggestionChip( + modifier = Modifier.weight(1f), + onClick = { onPercentSelected(percent) }, + label = { + Text( + text = "$percent%", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + }, + ) + } + } +} diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseSummaryRow.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseSummaryRow.kt new file mode 100644 index 0000000000..fc9cdb258c --- /dev/null +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/AutocloseSummaryRow.kt @@ -0,0 +1,38 @@ +package com.gemwallet.android.ui.components.perpetual + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.stringResource +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.list_item.ListItemSupportText +import com.gemwallet.android.ui.components.list_item.property.PropertyItem +import com.gemwallet.android.ui.components.list_item.property.PropertyTitleText +import com.gemwallet.android.ui.models.ListPosition +import com.gemwallet.android.ui.theme.space2 + +@Composable +fun AutocloseSummaryRow( + takeProfitText: String?, + stopLossText: String?, + listPosition: ListPosition = ListPosition.Single, +) { + val lines = listOfNotNull( + takeProfitText?.let { "${stringResource(R.string.charts_take_profit)}: $it" }, + stopLossText?.let { "${stringResource(R.string.charts_stop_loss)}: $it" }, + ) + if (lines.isEmpty()) return + PropertyItem( + title = { PropertyTitleText(stringResource(R.string.perpetual_auto_close)) }, + data = { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(space2), + ) { + lines.forEach { ListItemSupportText(it) } + } + }, + listPosition = listPosition, + ) +} diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualConfirmDetailsComponents.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualConfirmDetailsComponents.kt index c659e7d40f..7db79f8934 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualConfirmDetailsComponents.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualConfirmDetailsComponents.kt @@ -1,7 +1,6 @@ package com.gemwallet.android.ui.components.perpetual import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -9,10 +8,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import com.gemwallet.android.ui.R -import com.gemwallet.android.ui.components.list_item.ListItem -import com.gemwallet.android.ui.components.list_item.ListItemDefaults -import com.gemwallet.android.ui.components.list_item.ListItemSupportText -import com.gemwallet.android.ui.components.list_item.ListItemTitleText import com.gemwallet.android.ui.components.list_item.color import com.gemwallet.android.ui.components.list_item.property.DataBadgeChevron import com.gemwallet.android.ui.components.list_item.property.PropertyDataText @@ -22,7 +17,6 @@ import com.gemwallet.android.ui.components.screen.ModalBottomSheet import com.gemwallet.android.ui.models.ListPosition import com.gemwallet.android.ui.models.perpetual.PerpetualConfirmDetailsUIModel import com.gemwallet.android.ui.models.perpetual.PerpetualConfirmDetailsUIModel.Action -import com.gemwallet.android.ui.theme.space2 @Composable fun PerpetualDetailsSummaryItem( @@ -82,9 +76,14 @@ fun PerpetualDetailsBottomSheet( data = model.sizeText, listPosition = ListPosition.Last, ) - model.autoclose?.let { AutocloseRow(it) } + model.autoclose?.let { + AutocloseSummaryRow( + takeProfitText = it.takeProfitText, + stopLossText = it.stopLossText, + ) + } PropertyItem( - title = stringResource(R.string.price_alerts_set_alert_current_price), + title = stringResource(R.string.perpetual_market_price), data = model.marketPriceText, listPosition = ListPosition.First, ) @@ -104,24 +103,6 @@ fun PerpetualDetailsBottomSheet( } } -@Composable -private fun AutocloseRow(autoclose: PerpetualConfirmDetailsUIModel.Autoclose) { - val lines = listOfNotNull( - autoclose.takeProfitText?.let { "${stringResource(R.string.charts_take_profit)}: $it" }, - autoclose.stopLossText?.let { "${stringResource(R.string.charts_stop_loss)}: $it" }, - ) - ListItem( - listPosition = ListPosition.Single, - minHeight = ListItemDefaults.defaultMinHeight, - title = { ListItemTitleText(text = stringResource(R.string.perpetual_auto_close)) }, - subtitle = { - Column(verticalArrangement = Arrangement.spacedBy(space2)) { - lines.forEach { ListItemSupportText(it) } - } - }, - ) -} - @Composable private fun PerpetualConfirmDetailsUIModel.summaryText(): String? = when (action) { Action.Open -> direction.titleAndLeverage(leverage) diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualTypeTitle.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualTypeTitle.kt index 61abf5e381..9af867b73a 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualTypeTitle.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/perpetual/PerpetualTypeTitle.kt @@ -12,7 +12,7 @@ fun PerpetualType.title(): String = when (this) { is PerpetualType.Increase -> stringResource(R.string.perpetual_increase_direction, directionLabel(content.direction)) is PerpetualType.Reduce -> stringResource(R.string.perpetual_reduce_direction, directionLabel(content.positionDirection)) is PerpetualType.Close -> stringResource(R.string.perpetual_close_position) - is PerpetualType.Modify -> stringResource(R.string.perpetual_modify) + is PerpetualType.Modify -> stringResource(R.string.perpetual_modify_position) } @Composable diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/viewmodel/NavEntryViewModelStoreOwner.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/viewmodel/NavEntryViewModelStoreOwner.kt new file mode 100644 index 0000000000..1f31db9ee3 --- /dev/null +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/viewmodel/NavEntryViewModelStoreOwner.kt @@ -0,0 +1,53 @@ +package com.gemwallet.android.ui.viewmodel + +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY +import androidx.lifecycle.SavedStateViewModelFactory +import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.enableSavedStateHandles +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.savedstate.SavedState +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryOwner + +class NavEntryViewModelStoreOwner( + private val parent: ViewModelStoreOwner, + private val store: ViewModelStore, + private val savedStateRegistryOwner: SavedStateRegistryOwner, + private val defaultArgs: SavedState, +) : ViewModelStoreOwner, + SavedStateRegistryOwner, + HasDefaultViewModelProviderFactory { + + init { + enableSavedStateHandles() + } + + override val viewModelStore: ViewModelStore + get() = store + + override val savedStateRegistry: SavedStateRegistry + get() = savedStateRegistryOwner.savedStateRegistry + + override val lifecycle + get() = savedStateRegistryOwner.lifecycle + + override val defaultViewModelProviderFactory: ViewModelProvider.Factory + get() = (parent as? HasDefaultViewModelProviderFactory)?.defaultViewModelProviderFactory + ?: SavedStateViewModelFactory() + + override val defaultViewModelCreationExtras: CreationExtras + get() = MutableCreationExtras( + (parent as? HasDefaultViewModelProviderFactory)?.defaultViewModelCreationExtras + ?: CreationExtras.Empty + ).apply { + this[SAVED_STATE_REGISTRY_OWNER_KEY] = this@NavEntryViewModelStoreOwner + this[VIEW_MODEL_STORE_OWNER_KEY] = this@NavEntryViewModelStoreOwner + this[DEFAULT_ARGS_KEY] = defaultArgs + } +} diff --git a/android/ui/src/main/res/values/strings.xml b/android/ui/src/main/res/values/strings.xml index e6ecb9ab6f..3bc413b13a 100644 --- a/android/ui/src/main/res/values/strings.xml +++ b/android/ui/src/main/res/values/strings.xml @@ -492,6 +492,9 @@ Verified This connection comes from an untrusted source. Default Leverage + Default Take Profit + Default Stop Loss + None Trade Perpetuals on Hyperliquid Deposit, trade, and earn with Hyperliquid perpetuals No data available @@ -595,4 +598,7 @@ Minimum Amount On the %s network, the minimum amount for this transaction is %s. Refunded + The remaining balance after this transfer would be dust. Try sending the maximum amount. + Trigger price should be lower than market price + Trigger price should be higher than market price diff --git a/core/gemstone/src/config/mod.rs b/core/gemstone/src/config/mod.rs index e2dce0f548..356a0b7b65 100644 --- a/core/gemstone/src/config/mod.rs +++ b/core/gemstone/src/config/mod.rs @@ -19,7 +19,7 @@ use std::{collections::HashMap, str::FromStr}; use { docs::{DocsUrl, get_docs_url}, - perpetual_config::{PerpetualConfig, get_perpetual_config, select_leverage}, + perpetual_config::{PerpetualConfig, get_autoclose_suggestions, get_perpetual_config, select_leverage}, public::{ASSETS_URL, PublicUrl, get_public_url}, rewards::{RewardsUrl, get_rewards_url}, social::{SocialUrl, get_social_url, get_social_url_deeplink}, @@ -60,6 +60,10 @@ impl Config { select_leverage(desired, &options) } + fn get_autoclose_suggestions(&self, leverage: u8) -> Vec { + get_autoclose_suggestions(leverage) + } + fn get_docs_url(&self, item: DocsUrl) -> String { get_docs_url(item) } diff --git a/core/gemstone/src/config/perpetual_config.rs b/core/gemstone/src/config/perpetual_config.rs index be9f5a2e8f..deea61a326 100644 --- a/core/gemstone/src/config/perpetual_config.rs +++ b/core/gemstone/src/config/perpetual_config.rs @@ -1,16 +1,31 @@ pub const DEFAULT_LEVERAGE: u8 = 5; pub const LEVERAGE_OPTIONS: &[u8] = &[1, 2, 3, 5, 10, 20, 25, 30, 40, 50]; +pub const TAKE_PROFIT_PERCENT_OPTIONS: &[u8] = &[0, 10, 25, 50, 100, 200]; +pub const STOP_LOSS_PERCENT_OPTIONS: &[u8] = &[0, 3, 5, 10, 25, 50]; +pub const DEFAULT_TAKE_PROFIT_PERCENT: u8 = 0; +pub const DEFAULT_STOP_LOSS_PERCENT: u8 = 0; +const AUTOCLOSE_SUGGESTION_TIERS: &[(u8, &[u8])] = &[(3, &[5, 10, 15]), (5, &[10, 15, 25]), (10, &[15, 25, 50])]; +const AUTOCLOSE_SUGGESTIONS_HIGH_LEVERAGE: &[u8] = &[25, 50, 100]; + #[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)] pub struct PerpetualConfig { pub default_leverage: u8, pub leverage_options: Vec, + pub take_profit_percent_options: Vec, + pub stop_loss_percent_options: Vec, + pub default_take_profit_percent: u8, + pub default_stop_loss_percent: u8, } pub fn get_perpetual_config() -> PerpetualConfig { PerpetualConfig { default_leverage: DEFAULT_LEVERAGE, leverage_options: LEVERAGE_OPTIONS.to_vec(), + take_profit_percent_options: TAKE_PROFIT_PERCENT_OPTIONS.to_vec(), + stop_loss_percent_options: STOP_LOSS_PERCENT_OPTIONS.to_vec(), + default_take_profit_percent: DEFAULT_TAKE_PROFIT_PERCENT, + default_stop_loss_percent: DEFAULT_STOP_LOSS_PERCENT, } } @@ -24,6 +39,14 @@ pub fn select_leverage(desired: u8, options: &[u8]) -> u8 { .unwrap_or(DEFAULT_LEVERAGE) } +pub fn get_autoclose_suggestions(leverage: u8) -> Vec { + AUTOCLOSE_SUGGESTION_TIERS + .iter() + .find(|&&(max_leverage, _)| leverage <= max_leverage) + .map_or(AUTOCLOSE_SUGGESTIONS_HIGH_LEVERAGE, |&(_, suggestions)| suggestions) + .to_vec() +} + #[cfg(test)] mod tests { use super::*; @@ -49,5 +72,17 @@ mod tests { let config = get_perpetual_config(); assert_eq!(config.default_leverage, DEFAULT_LEVERAGE); assert_eq!(config.leverage_options, LEVERAGE_OPTIONS); + assert_eq!(config.take_profit_percent_options, TAKE_PROFIT_PERCENT_OPTIONS); + assert_eq!(config.stop_loss_percent_options, STOP_LOSS_PERCENT_OPTIONS); + assert_eq!(config.default_take_profit_percent, DEFAULT_TAKE_PROFIT_PERCENT); + assert_eq!(config.default_stop_loss_percent, DEFAULT_STOP_LOSS_PERCENT); + } + + #[test] + fn test_get_autoclose_suggestions() { + assert_eq!(get_autoclose_suggestions(2), vec![5, 10, 15]); + assert_eq!(get_autoclose_suggestions(5), vec![10, 15, 25]); + assert_eq!(get_autoclose_suggestions(10), vec![15, 25, 50]); + assert_eq!(get_autoclose_suggestions(25), vec![25, 50, 100]); } } diff --git a/ios/Features/Perpetuals/Sources/ViewModels/AutocloseViewModel.swift b/ios/Features/Perpetuals/Sources/ViewModels/AutocloseViewModel.swift index 6adfbfa02c..8aa0e375b9 100644 --- a/ios/Features/Perpetuals/Sources/ViewModels/AutocloseViewModel.swift +++ b/ios/Features/Perpetuals/Sources/ViewModels/AutocloseViewModel.swift @@ -3,6 +3,7 @@ import Components import Formatters import Foundation +import GemstonePrimitives import Localization import Primitives import PrimitivesComponents @@ -68,12 +69,7 @@ public struct AutocloseViewModel { } public var percents: [Int] { - switch estimator.leverage { - case 0 ... 3: [5, 10, 15] - case 4 ... 5: [10, 15, 25] - case 4 ... 10: [15, 25, 50] - case _: [25, 50, 100] - } + PerpetualConfig.autocloseSuggestions(leverage: estimator.leverage).map { Int($0) } } public var percentSuggestions: [PercentageSuggestion] { diff --git a/ios/Features/Settings/Sources/Settings/Scenes/PreferencesScene.swift b/ios/Features/Settings/Sources/Settings/Scenes/PreferencesScene.swift index 02bff272c4..c31329c663 100644 --- a/ios/Features/Settings/Sources/Settings/Scenes/PreferencesScene.swift +++ b/ios/Features/Settings/Sources/Settings/Scenes/PreferencesScene.swift @@ -58,14 +58,21 @@ public struct PreferencesScene: View { ) if model.isPerpetualEnabled { - NavigationCustomLink( - with: ListItemView( - title: model.defaultLeverageTitle, - subtitle: model.defaultLeverageValue, - ), + perpetualLink( + title: model.defaultLeverageTitle, + value: model.defaultLeverageValue, action: model.onSelectLeverage, ) - .padding(.leading, Sizing.image.asset - .tiny) + perpetualLink( + title: model.defaultTakeProfitTitle, + value: model.defaultTakeProfitValue, + action: model.onSelectTakeProfit, + ) + perpetualLink( + title: model.defaultStopLossTitle, + value: model.defaultStopLossValue, + action: model.onSelectStopLoss, + ) } } } @@ -75,12 +82,38 @@ public struct PreferencesScene: View { .listSectionSpacing(.compact) .navigationTitle(model.title) .sheet(isPresented: $model.isPresentingLeveragePicker) { - LeveragePickerSheet( + WheelPickerSheet( title: model.defaultLeverageTitle, - leverageOptions: model.leverageOptions, - selectedLeverage: $model.perpetualLeverage, + options: model.leverageOptions, + selection: $model.perpetualLeverage, + ) + } + .sheet(isPresented: $model.isPresentingTakeProfitPicker) { + WheelPickerSheet( + title: model.defaultTakeProfitTitle, + options: model.takeProfitOptions, + selection: $model.perpetualTakeProfit, ) } + .sheet(isPresented: $model.isPresentingStopLossPicker) { + WheelPickerSheet( + title: model.defaultStopLossTitle, + options: model.stopLossOptions, + selection: $model.perpetualStopLoss, + ) + } + } + + private func perpetualLink( + title: String, + value: String, + action: @escaping @MainActor () -> Void, + ) -> some View { + NavigationCustomLink( + with: ListItemView(title: title, subtitle: value), + action: action, + ) + .padding(.leading, Sizing.image.asset - .tiny) } } diff --git a/ios/Features/Settings/Sources/Settings/ViewModels/PreferencesViewModel.swift b/ios/Features/Settings/Sources/Settings/ViewModels/PreferencesViewModel.swift index 37c2ca9d88..0674671f43 100644 --- a/ios/Features/Settings/Sources/Settings/ViewModels/PreferencesViewModel.swift +++ b/ios/Features/Settings/Sources/Settings/ViewModels/PreferencesViewModel.swift @@ -2,6 +2,7 @@ import Components import Foundation +import GemstonePrimitives import Localization import Preferences import Primitives @@ -16,6 +17,8 @@ public final class PreferencesViewModel { private let currencyModel: CurrencySceneViewModel var isPresentingLeveragePicker = false + var isPresentingTakeProfitPicker = false + var isPresentingStopLossPicker = false public init( currencyModel: CurrencySceneViewModel, @@ -85,8 +88,12 @@ public final class PreferencesViewModel { AssetImage.image(Images.Settings.perpetuals) } + private var leverage: UInt8 { + preferences.perpetualLeverage == 0 ? PerpetualConfig.defaultLeverage : preferences.perpetualLeverage + } + var perpetualLeverage: LeverageOption { - get { LeverageOption(value: preferences.perpetualLeverage) } + get { LeverageOption(value: leverage) } set { preferences.perpetualLeverage = newValue.value } } @@ -95,12 +102,46 @@ public final class PreferencesViewModel { } var defaultLeverageValue: String { - "\(preferences.perpetualLeverage)x" + "\(leverage)x" } var leverageOptions: [LeverageOption] { LeverageOption.allOptions } + + var perpetualTakeProfit: AutocloseOption { + get { AutocloseOption(value: preferences.perpetualTakeProfit) } + set { preferences.perpetualTakeProfit = newValue.value } + } + + var perpetualStopLoss: AutocloseOption { + get { AutocloseOption(value: preferences.perpetualStopLoss) } + set { preferences.perpetualStopLoss = newValue.value } + } + + var defaultTakeProfitTitle: String { + Localized.Settings.Preferences.defaultTakeProfit + } + + var defaultStopLossTitle: String { + Localized.Settings.Preferences.defaultStopLoss + } + + var defaultTakeProfitValue: String { + perpetualTakeProfit.displayText + } + + var defaultStopLossValue: String { + perpetualStopLoss.displayText + } + + var takeProfitOptions: [AutocloseOption] { + AutocloseOption.takeProfitOptions + } + + var stopLossOptions: [AutocloseOption] { + AutocloseOption.stopLossOptions + } } // MARK: - Actions @@ -109,4 +150,12 @@ extension PreferencesViewModel { func onSelectLeverage() { isPresentingLeveragePicker = true } + + func onSelectTakeProfit() { + isPresentingTakeProfitPicker = true + } + + func onSelectStopLoss() { + isPresentingStopLossPicker = true + } } diff --git a/ios/Features/Transfer/Sources/ViewModels/AmountPerpetualViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/AmountPerpetualViewModel.swift index 0185b548b3..ef0842ee51 100644 --- a/ios/Features/Transfer/Sources/ViewModels/AmountPerpetualViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/AmountPerpetualViewModel.swift @@ -29,19 +29,23 @@ public final class AmountPerpetualViewModel: AmountDataProvidable { var takeProfit: String? var stopLoss: String? - private var transferData: PerpetualTransferData { - data.positionAction.transferData - } - - private var leverage: UInt8 { - leverageSelection?.selected.value ?? transferData.leverage - } + private let takeProfitPercent: UInt8 + private let stopLossPercent: UInt8 + private var isAutocloseEdited = false init(asset: Asset, data: PerpetualRecipientData, preferences: Preferences = .standard) { self.asset = asset self.data = data currencyFormatter = CurrencyFormatter(type: .currency, currencyCode: preferences.currency) + takeProfitPercent = preferences.perpetualTakeProfit + stopLossPercent = preferences.perpetualStopLoss (leverageSelection, leverageTextStyle) = Self.makeLeverageSelection(data: data, preferences: preferences) + (takeProfit, stopLoss) = Self.makeDefaultAutoclose( + data: data, + leverage: leverageSelection?.selected.value ?? data.positionAction.transferData.leverage, + takeProfitPercent: takeProfitPercent, + stopLossPercent: stopLossPercent, + ) } var leverageTitle: String { @@ -52,6 +56,14 @@ public final class AmountPerpetualViewModel: AmountDataProvidable { Localized.Perpetual.autoClose } + private var transferData: PerpetualTransferData { + data.positionAction.transferData + } + + private var leverage: UInt8 { + leverageSelection?.selected.value ?? transferData.leverage + } + var isAutocloseEnabled: Bool { switch data.positionAction { case .open: true @@ -155,6 +167,22 @@ public final class AmountPerpetualViewModel: AmountDataProvidable { ) } + func onChangeLeverage() { + guard !isAutocloseEdited else { return } + (takeProfit, stopLoss) = Self.makeDefaultAutoclose( + data: data, + leverage: leverage, + takeProfitPercent: takeProfitPercent, + stopLossPercent: stopLossPercent, + ) + } + + func updateAutoclose(takeProfit: String?, stopLoss: String?) { + isAutocloseEdited = true + self.takeProfit = takeProfit + self.stopLoss = stopLoss + } + private static func makeLeverageSelection( data: PerpetualRecipientData, preferences: Preferences, @@ -165,7 +193,8 @@ public final class AmountPerpetualViewModel: AmountDataProvidable { let transferData = data.positionAction.transferData let options = LeverageOption.allOptions.filter { $0.value <= transferData.leverage } - let selected = LeverageOption.option(desiredValue: preferences.perpetualLeverage, from: options) + let desiredLeverage = preferences.perpetualLeverage == 0 ? PerpetualConfig.defaultLeverage : preferences.perpetualLeverage + let selected = LeverageOption.option(desiredValue: desiredLeverage, from: options) let textStyle = TextStyle( font: .callout, color: PerpetualDirectionViewModel(direction: openData.direction).color, @@ -180,4 +209,35 @@ public final class AmountPerpetualViewModel: AmountDataProvidable { return (selection, textStyle) } + + private static func makeDefaultAutoclose( + data: PerpetualRecipientData, + leverage: UInt8, + takeProfitPercent: UInt8, + stopLossPercent: UInt8, + ) -> (takeProfit: String?, stopLoss: String?) { + guard case .open = data.positionAction else { + return (nil, nil) + } + guard takeProfitPercent > 0 || stopLossPercent > 0 else { + return (nil, nil) + } + + let transferData = data.positionAction.transferData + let estimator = AutocloseEstimator( + entryPrice: transferData.price, + positionSize: 0, + direction: transferData.direction, + leverage: leverage, + ) + let formatter = PerpetualFormatter(provider: .hypercore) + func price(_ percent: UInt8, _ type: TpslType) -> String? { + guard percent > 0 else { return nil } + return formatter.formatInputPrice( + estimator.calculateTargetPriceFromROE(roePercent: Int(percent), type: type), + decimals: transferData.asset.decimals, + ) + } + return (price(takeProfitPercent, .takeProfit), price(stopLossPercent, .stopLoss)) + } } diff --git a/ios/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift index 065796be4e..4e03ded574 100644 --- a/ios/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift @@ -183,8 +183,10 @@ extension AmountSceneViewModel { public func onAutocloseComplete(takeProfit: InputValidationViewModel, stopLoss: InputValidationViewModel) { if case let .perpetual(perpetual) = provider { - perpetual.takeProfit = takeProfit.text.isEmpty ? nil : takeProfit.text - perpetual.stopLoss = stopLoss.text.isEmpty ? nil : stopLoss.text + perpetual.updateAutoclose( + takeProfit: takeProfit.text.isEmpty ? nil : takeProfit.text, + stopLoss: stopLoss.text.isEmpty ? nil : stopLoss.text, + ) } isPresentingSheet = nil } @@ -195,6 +197,9 @@ extension AmountSceneViewModel { public func onChangeLeverage(_: LeverageOption, _: LeverageOption) { amountInputModel.update(validators: inputValidators) + if case let .perpetual(perpetual) = provider { + perpetual.onChangeLeverage() + } } public func onValidatorSelected(_ validator: DelegationValidator) { diff --git a/ios/Gem/Navigation/Transfer/AmountNavigationView.swift b/ios/Gem/Navigation/Transfer/AmountNavigationView.swift index 1c213552b6..104a844717 100644 --- a/ios/Gem/Navigation/Transfer/AmountNavigationView.swift +++ b/ios/Gem/Navigation/Transfer/AmountNavigationView.swift @@ -35,10 +35,10 @@ struct AmountNavigationView: View { } case let .leverageSelector(selection): @Bindable var leverageSelection = selection - LeveragePickerSheet( + WheelPickerSheet( title: leverageSelection.title, - leverageOptions: leverageSelection.options, - selectedLeverage: $leverageSelection.selected, + options: leverageSelection.options, + selection: $leverageSelection.selected, ) .onChange(of: leverageSelection.selected, model.onChangeLeverage) case let .autoclose(openData): diff --git a/ios/Packages/GemstonePrimitives/Sources/PerpetualConfig.swift b/ios/Packages/GemstonePrimitives/Sources/PerpetualConfig.swift index 51b747f5c2..533c504eab 100644 --- a/ios/Packages/GemstonePrimitives/Sources/PerpetualConfig.swift +++ b/ios/Packages/GemstonePrimitives/Sources/PerpetualConfig.swift @@ -14,7 +14,19 @@ public struct PerpetualConfig { Array(Config.shared.getPerpetualConfig().leverageOptions) } + public static var takeProfitOptions: [UInt8] { + Array(Config.shared.getPerpetualConfig().takeProfitPercentOptions) + } + + public static var stopLossOptions: [UInt8] { + Array(Config.shared.getPerpetualConfig().stopLossPercentOptions) + } + public static func selectLeverage(desired: UInt8, options: [UInt8]) -> UInt8 { Config.shared.selectLeverage(desired: desired, options: Data(options)) } + + public static func autocloseSuggestions(leverage: UInt8) -> [UInt8] { + Array(Config.shared.getAutocloseSuggestions(leverage: leverage)) + } } diff --git a/ios/Packages/Localization/Sources/Localized.swift b/ios/Packages/Localization/Sources/Localized.swift index 0545417a9b..4a8a55283e 100644 --- a/ios/Packages/Localization/Sources/Localized.swift +++ b/ios/Packages/Localization/Sources/Localized.swift @@ -291,6 +291,8 @@ public enum Localized { public static let no = Localized.tr("Localizable", "common.no", fallback: "No") /// No Results Found public static let noResultsFound = Localized.tr("Localizable", "common.no_results_found", fallback: "No Results Found") + /// None + public static let none = Localized.tr("Localizable", "common.none", fallback: "None") /// Not Available public static let notAvailable = Localized.tr("Localizable", "common.not_available", fallback: "Not Available") /// Open settings @@ -399,6 +401,8 @@ public enum Localized { public static let decoding = Localized.tr("Localizable", "errors.decoding", fallback: "Decoding Error") /// Failed to decode the QR code. Please try again with a different QR code. public static let decodingQr = Localized.tr("Localizable", "errors.decoding_qr", fallback: "Failed to decode the QR code. Please try again with a different QR code.") + /// The remaining balance after this transfer would be dust. Try sending the maximum amount. + public static let dustChangeShort = Localized.tr("Localizable", "errors.dust_change_short", fallback: "The remaining balance after this transfer would be dust. Try sending the maximum amount.") /// The transaction failed because the amount is too small to meet the %@ network’s minimum requirement (dust threshold). This limit ensures the transaction value covers the fees and processing costs. public static func dustThreshold(_ p1: Any) -> String { return Localized.tr("Localizable", "errors.dust_threshold", String(describing: p1), fallback: "The transaction failed because the amount is too small to meet the %@ network’s minimum requirement (dust threshold). This limit ensures the transaction value covers the fees and processing costs.") @@ -471,6 +475,12 @@ public enum Localized { return Localized.tr("Localizable", "errors.import.invalid_secret_phrase_word", String(describing: p1), fallback: "Invalid Secret Phrase word: %@") } } + public enum Perpetual { + /// Trigger price should be higher than market price + public static let triggerPriceHigher = Localized.tr("Localizable", "errors.perpetual.trigger_price_higher", fallback: "Trigger price should be higher than market price") + /// Trigger price should be lower than market price + public static let triggerPriceLower = Localized.tr("Localizable", "errors.perpetual.trigger_price_lower", fallback: "Trigger price should be lower than market price") + } public enum ScanTransaction { /// %@ destination wallet address requires a destination tag / memo public static func memoRequired(_ p1: Any) -> String { @@ -1231,6 +1241,10 @@ public enum Localized { public static let title = Localized.tr("Localizable", "settings.notifications.title", fallback: "Notifications") } public enum Preferences { + /// Default Stop Loss + public static let defaultStopLoss = Localized.tr("Localizable", "settings.preferences.default_stop_loss", fallback: "Default Stop Loss") + /// Default Take Profit + public static let defaultTakeProfit = Localized.tr("Localizable", "settings.preferences.default_take_profit", fallback: "Default Take Profit") /// Default Leverage public static let defaultLeverage = Localized.tr("Localizable", "settings.preferences.default_leverage", fallback: "Default Leverage") /// Preferences diff --git a/ios/Packages/Localization/Sources/Resources/en.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/en.lproj/Localizable.strings index c4928b966b..87b9cba758 100644 --- a/ios/Packages/Localization/Sources/Resources/en.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/en.lproj/Localizable.strings @@ -187,6 +187,7 @@ "errors.invalid_url" = "Invalid URL"; "common.yes" = "Yes"; "common.no" = "No"; +"common.none" = "None"; "common.url" = "URL"; "errors.error_occured" = "An error occurred!"; "settings.language" = "Language"; @@ -471,6 +472,8 @@ "asset.verification.verified" = "Verified"; "errors.connections.malicious_origin" = "This connection comes from an untrusted source."; "settings.preferences.default_leverage" = "Default Leverage"; +"settings.preferences.default_take_profit" = "Default Take Profit"; +"settings.preferences.default_stop_loss" = "Default Stop Loss"; "banner.perpetuals.title" = "Trade Perpetuals on Hyperliquid"; "banner.perpetuals.description" = "Deposit, trade, and earn with Hyperliquid perpetuals"; "errors.no_data_available" = "No data available"; @@ -571,4 +574,7 @@ "info.funding_apr.description" = "The annualized rate at which longs pay shorts (if negative, shorts pay longs). There are no fees associated with funding, which is a peer-to-peer transfer between users to push prices towards the spot price."; "info.minimum_amount.title" = "Minimum Amount"; "info.minimum_amount.description" = "On the %@ network, the minimum amount for this transaction is %@."; -"transaction.status.refunded" = "Refunded"; \ No newline at end of file +"transaction.status.refunded" = "Refunded"; +"errors.dust_change_short" = "The remaining balance after this transfer would be dust. Try sending the maximum amount."; +"errors.perpetual.trigger_price_lower" = "Trigger price should be lower than market price"; +"errors.perpetual.trigger_price_higher" = "Trigger price should be higher than market price"; \ No newline at end of file diff --git a/ios/Packages/Preferences/Sources/ObservablePreferences.swift b/ios/Packages/Preferences/Sources/ObservablePreferences.swift index 139564b138..b2137da768 100644 --- a/ios/Packages/Preferences/Sources/ObservablePreferences.swift +++ b/ios/Packages/Preferences/Sources/ObservablePreferences.swift @@ -108,6 +108,32 @@ public final class ObservablePreferences: Sendable { } } + @ObservationIgnored + public var perpetualTakeProfit: UInt8 { + get { + access(keyPath: \.perpetualTakeProfit) + return preferences.perpetualTakeProfit + } + set { + withMutation(keyPath: \.perpetualTakeProfit) { + preferences.perpetualTakeProfit = newValue + } + } + } + + @ObservationIgnored + public var perpetualStopLoss: UInt8 { + get { + access(keyPath: \.perpetualStopLoss) + return preferences.perpetualStopLoss + } + set { + withMutation(keyPath: \.perpetualStopLoss) { + preferences.perpetualStopLoss = newValue + } + } + } + public func showPerpetuals(for wallet: Wallet) -> Bool { access(keyPath: \.isPerpetualEnabled) return preferences.showPerpetuals(for: wallet) diff --git a/ios/Packages/Preferences/Sources/Preferences.swift b/ios/Packages/Preferences/Sources/Preferences.swift index 3a2af6859c..432b18ee12 100644 --- a/ios/Packages/Preferences/Sources/Preferences.swift +++ b/ios/Packages/Preferences/Sources/Preferences.swift @@ -34,6 +34,8 @@ public final class Preferences: @unchecked Sendable { static let perpetualPricesUpdatedAt = "perpetual_prices_updated_at" static let isPerpetualEnabled = "is_perpetual_enabled" static let perpetualLeverage = "perpetual_leverage" + static let perpetualTakeProfit = "perpetual_take_profit" + static let perpetualStopLoss = "perpetual_stop_loss" static let isDeviceRegistered = "is_device_registered" } @@ -109,9 +111,15 @@ public final class Preferences: @unchecked Sendable { @ConfigurableDefaults(key: Keys.isPerpetualEnabled, defaultValue: false) public var isPerpetualEnabled: Bool - @ConfigurableDefaults(key: Keys.perpetualLeverage, defaultValue: 10) + @ConfigurableDefaults(key: Keys.perpetualLeverage, defaultValue: 0) public var perpetualLeverage: UInt8 + @ConfigurableDefaults(key: Keys.perpetualTakeProfit, defaultValue: 0) + public var perpetualTakeProfit: UInt8 + + @ConfigurableDefaults(key: Keys.perpetualStopLoss, defaultValue: 0) + public var perpetualStopLoss: UInt8 + @ConfigurableDefaults(key: Keys.isDeviceRegistered, defaultValue: false) public var isDeviceRegistered: Bool @@ -153,7 +161,9 @@ public final class Preferences: @unchecked Sendable { configure(\._perpetualMarketsUpdatedAt, key: Keys.perpetualsMarketsUpdatedAt, defaultValue: nil) configure(\._perpetualPricesUpdatedAt, key: Keys.perpetualPricesUpdatedAt, defaultValue: nil) configure(\._isPerpetualEnabled, key: Keys.isPerpetualEnabled, defaultValue: false) - configure(\._perpetualLeverage, key: Keys.perpetualLeverage, defaultValue: 10) + configure(\._perpetualLeverage, key: Keys.perpetualLeverage, defaultValue: 0) + configure(\._perpetualTakeProfit, key: Keys.perpetualTakeProfit, defaultValue: 0) + configure(\._perpetualStopLoss, key: Keys.perpetualStopLoss, defaultValue: 0) configure(\._isDeviceRegistered, key: Keys.isDeviceRegistered, defaultValue: false) } diff --git a/ios/Packages/Preferences/Tests/PreferencesTests.swift b/ios/Packages/Preferences/Tests/PreferencesTests.swift index b029cb9513..bd9afa9c7a 100644 --- a/ios/Packages/Preferences/Tests/PreferencesTests.swift +++ b/ios/Packages/Preferences/Tests/PreferencesTests.swift @@ -31,7 +31,7 @@ struct PreferencesTests { #expect(!preferences.isDeveloperEnabled) #expect(!preferences.isHideBalanceEnabled) #expect(preferences.skippedReleaseVersion == nil) - #expect(preferences.perpetualLeverage == 10) + #expect(preferences.perpetualLeverage == 0) } @Test @@ -182,7 +182,7 @@ struct PreferencesTests { #expect(!preferences.isHideBalanceEnabled) #expect(preferences.explorerName(chain: .bitcoin) == nil) #expect(preferences.skippedReleaseVersion == nil) - #expect(preferences.perpetualLeverage == 10) + #expect(preferences.perpetualLeverage == 0) } @Test diff --git a/ios/Packages/PrimitivesComponents/Sources/Scenes/LeveragePickerSheet.swift b/ios/Packages/PrimitivesComponents/Sources/Scenes/LeveragePickerSheet.swift deleted file mode 100644 index 30c17f8bbc..0000000000 --- a/ios/Packages/PrimitivesComponents/Sources/Scenes/LeveragePickerSheet.swift +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Components -import SwiftUI - -public struct LeveragePickerSheet: View { - private let title: String - private let leverageOptions: [LeverageOption] - @Binding var selectedLeverage: LeverageOption - - public init(title: String, leverageOptions: [LeverageOption], selectedLeverage: Binding) { - self.title = title - self.leverageOptions = leverageOptions - _selectedLeverage = selectedLeverage - } - - public var body: some View { - NavigationStack { - LeveragePickerView( - leverageOptions: leverageOptions, - selectedLeverage: $selectedLeverage, - ) - .navigationBarTitleDisplayMode(.inline) - .navigationTitle(title) - .toolbar { - ToolbarDismissItem( - type: .close, - placement: .topBarLeading, - ) - } - } - .presentationDetents([.height(300)]) - } -} diff --git a/ios/Packages/PrimitivesComponents/Sources/Scenes/WheelPickerSheet.swift b/ios/Packages/PrimitivesComponents/Sources/Scenes/WheelPickerSheet.swift new file mode 100644 index 0000000000..46362744ab --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/Scenes/WheelPickerSheet.swift @@ -0,0 +1,31 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import SwiftUI + +public struct WheelPickerSheet: View { + private let title: String + private let options: [T] + @Binding private var selection: T + + public init(title: String, options: [T], selection: Binding) { + self.title = title + self.options = options + _selection = selection + } + + public var body: some View { + NavigationStack { + WheelPickerView(options: options, selection: $selection) + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(title) + .toolbar { + ToolbarDismissItem( + type: .close, + placement: .topBarLeading, + ) + } + } + .presentationDetents([.height(300)]) + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/Types/AutocloseOption.swift b/ios/Packages/PrimitivesComponents/Sources/Types/AutocloseOption.swift new file mode 100644 index 0000000000..656d315a03 --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/Types/AutocloseOption.swift @@ -0,0 +1,30 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import Foundation +import GemstonePrimitives +import Localization + +public struct AutocloseOption: WheelPickerDisplayable, Sendable { + public static var takeProfitOptions: [AutocloseOption] { + PerpetualConfig.takeProfitOptions.map { .init(value: $0) } + } + + public static var stopLossOptions: [AutocloseOption] { + PerpetualConfig.stopLossOptions.map { .init(value: $0) } + } + + public let value: UInt8 + + public init(value: UInt8) { + self.value = value + } + + public var id: UInt8 { + value + } + + public var displayText: String { + value == 0 ? Localized.Common.none : "\(value)%" + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/Types/LeverageOption.swift b/ios/Packages/PrimitivesComponents/Sources/Types/LeverageOption.swift index 971069e0c7..2bceca3f51 100644 --- a/ios/Packages/PrimitivesComponents/Sources/Types/LeverageOption.swift +++ b/ios/Packages/PrimitivesComponents/Sources/Types/LeverageOption.swift @@ -4,7 +4,7 @@ import Components import Foundation import GemstonePrimitives -public struct LeverageOption: WheelPickerDisplayable, Comparable, Sendable { +public struct LeverageOption: WheelPickerDisplayable, Sendable { public static let allOptions: [LeverageOption] = PerpetualConfig.leverageOptions.map { .init(value: $0) } public let value: UInt8 @@ -21,10 +21,6 @@ public struct LeverageOption: WheelPickerDisplayable, Comparable, Sendable { "\(value)x" } - public static func < (lhs: LeverageOption, rhs: LeverageOption) -> Bool { - lhs.value < rhs.value - } - public static func option(desiredValue: UInt8, from available: [LeverageOption]) -> LeverageOption { LeverageOption( value: PerpetualConfig.selectLeverage( diff --git a/ios/Packages/PrimitivesComponents/Sources/Views/LeveragePickerView.swift b/ios/Packages/PrimitivesComponents/Sources/Views/LeveragePickerView.swift deleted file mode 100644 index 0f51444b27..0000000000 --- a/ios/Packages/PrimitivesComponents/Sources/Views/LeveragePickerView.swift +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Components -import SwiftUI - -struct LeveragePickerView: View { - private let leverageOptions: [LeverageOption] - @Binding private var selectedLeverage: LeverageOption - - init(leverageOptions: [LeverageOption], selectedLeverage: Binding) { - self.leverageOptions = leverageOptions - _selectedLeverage = selectedLeverage - } - - var body: some View { - WheelPickerView( - options: leverageOptions, - selection: $selectedLeverage, - ) - } -} diff --git a/ios/Packages/Validators/Sources/Errors/PerpetualError.swift b/ios/Packages/Validators/Sources/Errors/PerpetualError.swift index 2c2e1fa315..2a04d4ddad 100644 --- a/ios/Packages/Validators/Sources/Errors/PerpetualError.swift +++ b/ios/Packages/Validators/Sources/Errors/PerpetualError.swift @@ -12,11 +12,10 @@ extension PerpetualError: LocalizedError { var errorDescription: String? { switch self { case let .invalidAutoclose(type, direction): - let comparison = switch (type, direction) { - case (.takeProfit, .long), (.stopLoss, .short): "higher" - case (.takeProfit, .short), (.stopLoss, .long): "lower" + switch (type, direction) { + case (.takeProfit, .long), (.stopLoss, .short): Localized.Errors.Perpetual.triggerPriceHigher + case (.takeProfit, .short), (.stopLoss, .long): Localized.Errors.Perpetual.triggerPriceLower } - return "Trigger price should be \(comparison) than market price" // TODO: Localized } } }