Updated on 2026-08-14

This commit is contained in:
Tangem 2024-02-29 13:50:16 +00:00
commit 6d0b295ca3
219 changed files with 3720 additions and 1687 deletions

View file

@ -1,13 +1,22 @@
package com.tangem.managetokens.presentation.addcustomtoken.ui
import android.annotation.SuppressLint
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalBottomSheetDefaults
import androidx.compose.material3.ModalBottomSheetProperties
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@ -15,8 +24,18 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -29,6 +48,7 @@ import com.tangem.managetokens.presentation.addcustomtoken.router.AddCustomToken
import com.tangem.managetokens.presentation.addcustomtoken.router.AddCustomTokenRouter
import com.tangem.managetokens.presentation.addcustomtoken.viewmodels.AddCustomTokenViewModel
import com.tangem.managetokens.presentation.common.state.ChooseWalletState
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -46,14 +66,14 @@ fun AddCustomTokenBottomSheet(config: TangemBottomSheetConfig) {
onDispose { viewModel.onDispose() }
}
// FIXME: handle back presses after updating material3 to 1.2.0
ModalBottomSheet(
ModalBottomSheetWithBackHandling(
onDismissRequest = config.onDismissRequest,
sheetState = sheetState,
containerColor = TangemTheme.colors.background.tertiary,
shape = TangemTheme.shapes.bottomSheetLarge,
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top),
dragHandle = { TangemBottomSheetDraggableHeader(color = TangemTheme.colors.background.tertiary) },
properties = ModalBottomSheetDefaults.properties(shouldDismissOnBackPress = false),
) {
Content(onDismissRequest = config.onDismissRequest, viewModel = viewModel)
}
@ -68,6 +88,68 @@ fun AddCustomTokenBottomSheet(config: TangemBottomSheetConfig) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ModalBottomSheetWithBackHandling(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
containerColor: Color = BottomSheetDefaults.ContainerColor,
shape: Shape = BottomSheetDefaults.ExpandedShape,
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
sheetState: SheetState = rememberModalBottomSheetState(),
properties: ModalBottomSheetProperties = ModalBottomSheetDefaults.properties(),
content: @Composable ColumnScope.() -> Unit,
) {
val scope = rememberCoroutineScope()
BackHandler(enabled = sheetState.targetValue != SheetValue.Hidden) {
// Always catch back here, but only let it dismiss if shouldDismissOnBackPress.
// If not, it will have no effect.
if (properties.shouldDismissOnBackPress) {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) {
onDismissRequest()
}
}
}
}
val requester = remember { FocusRequester() }
val backPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current
ModalBottomSheet(
onDismissRequest = onDismissRequest,
containerColor = containerColor,
shape = shape,
windowInsets = windowInsets,
dragHandle = dragHandle,
sheetState = sheetState,
modifier = modifier
.focusRequester(requester)
.focusable()
.onPreviewKeyEvent {
if (it.key == Key.Back && it.type == KeyEventType.KeyUp && !it.nativeKeyEvent.isCanceled) {
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
return@onPreviewKeyEvent true
}
return@onPreviewKeyEvent false
},
properties = ModalBottomSheetDefaults.properties(
securePolicy = properties.securePolicy,
isFocusable = properties.isFocusable,
// Set false otherwise the onPreviewKeyEvent doesn't work at all.
// The functionality of shouldDismissOnBackPress is achieved by the BackHandler.
shouldDismissOnBackPress = false,
),
content = content,
)
LaunchedEffect(Unit) {
requester.requestFocus()
}
}
@SuppressLint("RestrictedApi")
@Composable
private fun Content(viewModel: AddCustomTokenViewModel, onDismissRequest: () -> Unit) {
@ -82,6 +164,10 @@ private fun Content(viewModel: AddCustomTokenViewModel, onDismissRequest: () ->
}
}
BackHandler(true) {
navController.popBackStack()
}
val router = remember(navController) { AddCustomTokenRouter(navController) }
viewModel.router = router

View file

@ -5,7 +5,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import arrow.core.getOrElse
@ -105,10 +104,6 @@ internal class AddCustomTokenViewModel @Inject constructor(
uiState = stateFactory.getInitialState()
}
override fun onDestroy(owner: LifecycleOwner) {
uiState = stateFactory.getInitialState()
}
private suspend fun selectSuitableWallet(suitableUserWallets: List<UserWallet>): UserWalletId? {
val selectedWallet = getSelectedWalletSyncUseCase().getOrNull()
val selectedWalletId = if (walletSupportsAddingTokens(selectedWallet) && suitableUserWallets.isNotEmpty()) {
@ -424,7 +419,7 @@ internal class AddCustomTokenViewModel @Inject constructor(
selectedWallet: UserWallet,
cryptoCurrency: CryptoCurrency,
): Boolean {
val currenciesList = getCurrenciesUseCase(selectedWallet.walletId).getOrElse { emptyList() }
val currenciesList = getCurrenciesUseCase.getSync(selectedWallet.walletId).getOrElse { emptyList() }
return when (cryptoCurrency) {
is CryptoCurrency.Coin -> {
currenciesList.any {

View file

@ -1,5 +1,6 @@
package com.tangem.managetokens.presentation.managetokens.state
import com.tangem.core.ui.components.marketprice.PriceChangeType
import kotlinx.collections.immutable.ImmutableList
internal sealed class QuotesState {
@ -10,8 +11,4 @@ internal sealed class QuotesState {
val changeType: PriceChangeType,
val chartData: ImmutableList<Float>,
) : QuotesState()
}
enum class PriceChangeType {
UP, DOWN
}

View file

@ -1,8 +1,9 @@
package com.tangem.managetokens.presentation.managetokens.state.factory
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.tokens.model.Quote
import com.tangem.managetokens.presentation.managetokens.state.PriceChangeType
import com.tangem.managetokens.presentation.managetokens.state.QuotesState
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
@ -16,19 +17,18 @@ internal class QuotesToQuotesStateConverter : Converter<Quote, QuotesState> {
priceChange = BigDecimalFormatter.formatPercent(
percent = priceChange.movePointLeft(2),
useAbsoluteValue = true,
maxFractionDigits = 1,
minFractionDigits = 1,
),
changeType = priceChange.getPriceChangeType(),
chartData = // TODO (in [REDACTED_TASK_KEY] when endpoint is ready)
when (priceChange.getPriceChangeType()) {
PriceChangeType.UP -> persistentListOf(0f, 5f, 10f, 30f)
PriceChangeType.DOWN -> persistentListOf(15f, 12f, 13f, 18f, 10f, 3f)
PriceChangeType.NEUTRAL -> persistentListOf(0f, 0f, 0f, 0f)
},
)
}
private fun BigDecimal.getPriceChangeType(): PriceChangeType {
return if (this >= BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN
return PriceChangeConverter.fromBigDecimal(value = this)
}
}

View file

@ -32,6 +32,7 @@ internal object ManageTokensStatePreviewData {
get() = listOf(
TokenItemStatePreviewData.loadedPriceDown,
TokenItemStatePreviewData.loadedPriceUp,
TokenItemStatePreviewData.loadedPriceNeutral,
)
private val searchState: SearchBarState

View file

@ -2,7 +2,11 @@ package com.tangem.managetokens.presentation.managetokens.state.previewdata
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color
import com.tangem.managetokens.presentation.managetokens.state.*
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.managetokens.presentation.managetokens.state.QuotesState
import com.tangem.managetokens.presentation.managetokens.state.TokenButtonType
import com.tangem.managetokens.presentation.managetokens.state.TokenIconState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
import kotlinx.collections.immutable.persistentListOf
internal object TokenItemStatePreviewData {
@ -46,6 +50,24 @@ internal object TokenItemStatePreviewData {
chooseNetworkState = ChooseNetworkStatePreviewData.state,
)
val loadedPriceNeutral: TokenItemState
get() = TokenItemState.Loaded(
id = "BTC",
name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin",
tokenId = "BTC",
currencySymbol = "BTC",
tokenIcon = tokenIconState,
quotes = QuotesState.Content(
priceChange = "0.00%",
changeType = PriceChangeType.NEUTRAL,
chartData = persistentListOf(10f, 2f, 5f, 3f, 4f, 8f, 9f, 7f, 10f),
),
rate = "31 285.72$",
availableAction = mutableStateOf(TokenButtonType.ADD),
onButtonClick = {},
chooseNetworkState = ChooseNetworkStatePreviewData.state,
)
private val tokenIconState: TokenIconState
get() = TokenIconState(
iconReference = null,

View file

@ -120,4 +120,14 @@ private fun Chart_Negative_Preview() {
persistentListOf(10f, 2f, 4f, 1f, 5f),
)
}
}
@Preview(widthDp = 150, heightDp = 150, showBackground = true)
@Composable
private fun Chart_Neutral_Preview() {
TangemTheme(isDark = true) {
PriceChangesChart(
persistentListOf(5f, 2f, 4f, 1f, 5f),
)
}
}

View file

@ -11,9 +11,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.managetokens.state.PriceChangeType
import com.tangem.managetokens.presentation.managetokens.state.QuotesState
@Composable
@ -48,11 +48,13 @@ private fun PriceChangeIcon(type: PriceChangeType?) {
id = when (animatedType) {
PriceChangeType.UP -> R.drawable.ic_arrow_up_8
PriceChangeType.DOWN -> R.drawable.ic_arrow_down_8
PriceChangeType.NEUTRAL -> R.drawable.ic_elipse_8
},
),
tint = when (animatedType) {
PriceChangeType.UP -> TangemTheme.colors.icon.accent
PriceChangeType.DOWN -> TangemTheme.colors.icon.warning
PriceChangeType.NEUTRAL -> TangemTheme.colors.icon.inactive
},
contentDescription = null,
)
@ -69,6 +71,7 @@ private fun PriceChangeText(type: PriceChangeType?, text: String?) {
color = when (type) {
PriceChangeType.UP -> TangemTheme.colors.text.accent
PriceChangeType.DOWN -> TangemTheme.colors.text.warning
PriceChangeType.NEUTRAL -> TangemTheme.colors.text.disabled
null -> TangemTheme.colors.text.primary1
},
overflow = TextOverflow.Ellipsis,

View file

@ -180,7 +180,8 @@ private fun BaseSurface(modifier: Modifier = Modifier, content: @Composable () -
}
}
@Preview()
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun Preview_Tokens_LightTheme(@PreviewParameter(TokenConfigProvider::class) state: TokenItemState) {
TangemTheme(isDark = false) {
@ -188,7 +189,7 @@ private fun Preview_Tokens_LightTheme(@PreviewParameter(TokenConfigProvider::cla
}
}
@Preview
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun Preview_Tokens_DarkTheme(@PreviewParameter(TokenConfigProvider::class) state: TokenItemState) {
TangemTheme(isDark = true) {
@ -201,5 +202,7 @@ private class TokenConfigProvider : CollectionPreviewParameterProvider<TokenItem
TokenItemStatePreviewData.tokenLoading,
TokenItemStatePreviewData.loadedPriceDown,
TokenItemStatePreviewData.loadedPriceUp,
TokenItemStatePreviewData.loadedPriceNeutral,
),
)
)
// endregion Preview

View file

@ -33,11 +33,15 @@ import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.Debouncer
import com.tangem.utils.coroutines.Debouncer.Companion.DEFAULT_WAIT_TIME_MS
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject
import kotlin.collections.set
import kotlin.properties.Delegates
@ -62,8 +66,6 @@ internal class ManageTokensViewModel @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
) : ViewModel(), ManageTokensClickIntents, ManageTokensUiEvents {
private val debouncer = Debouncer()
private val stateFactory = ManageTokensStateFactory(
currentStateProvider = Provider { uiState },
clickIntents = this,
@ -73,11 +75,17 @@ internal class ManageTokensViewModel @Inject constructor(
var uiState: ManageTokensState by mutableStateOf(stateFactory.getInitialState(flowOf(PagingData.from(emptyList()))))
private set
private var allAddedCurrencies: MutableList<CryptoCurrency> = mutableListOf()
private val currenciesListJobHolder: JobHolder = JobHolder()
private var wallets: List<UserWallet> by Delegates.notNull()
private val debouncer = Debouncer()
private var addedCurrenciesByWallet: MutableMap<UserWallet, MutableList<CryptoCurrency>> = mutableMapOf()
private var allAddedCurrencies: MutableList<CryptoCurrency> = Collections.synchronizedList(
mutableListOf<CryptoCurrency>(),
)
private var wallets: CopyOnWriteArrayList<UserWallet> by Delegates.notNull()
private var addedCurrenciesByWallet: MutableMap<UserWallet, MutableList<CryptoCurrency>> = ConcurrentHashMap()
private var selectedWallet: UserWallet? = null
@ -114,34 +122,53 @@ internal class ManageTokensViewModel @Inject constructor(
getWalletsUseCase()
.distinctUntilChanged()
.collectLatest { userWallets ->
wallets = userWallets.filter { it.isMultiCurrency && !it.isLocked }
wallets.map { wallet ->
val currencies = getCurrenciesUseCase(wallet.walletId).fold(
ifLeft = { emptyList() },
ifRight = { it },
)
allAddedCurrencies += currencies
addedCurrenciesByWallet[wallet] = currencies.toMutableList()
}
withContext(dispatchers.main) {
uiState = uiState.copy(tokens = getInitialTokensList())
}
selectedWallet = getSelectedWalletSyncUseCase().fold(
ifLeft = { null },
ifRight = { if (!it.isMultiCurrency || it.isLocked) null else it },
)
if (selectedWallet == null && wallets.isNotEmpty()) {
selectWalletUseCase(wallets.first().walletId)
selectedWallet = wallets.first()
}
updateDerivationNotificationState()
withContext(dispatchers.main) {
uiState = stateFactory.updateChooseWalletState(wallets, userWallets, selectedWallet)
}
launch {
subscribeToCurrencies(userWallets)
}.saveIn(currenciesListJobHolder)
}
}
}
private suspend fun subscribeToCurrencies(userWallets: List<UserWallet>) {
wallets = CopyOnWriteArrayList(userWallets.filter { it.isMultiCurrency && !it.isLocked })
combine(wallets.map { getCurrenciesUseCase.invoke(it.walletId).distinctUntilChanged() }) {
allAddedCurrencies.clear()
addedCurrenciesByWallet.clear()
val walletsWithCurrencies = wallets.zip(
it.map { currencyList ->
currencyList.getOrElse {
Timber.e("Couldn't retrieve currency list")
emptyList()
}
},
)
allAddedCurrencies = walletsWithCurrencies.flatMap { it.second }.toMutableList()
walletsWithCurrencies.forEach { (wallet, currencies) ->
addedCurrenciesByWallet[wallet] = currencies.toMutableList()
}
withContext(dispatchers.main) {
uiState = uiState.copy(tokens = getInitialTokensList())
}
selectedWallet = getSelectedWalletSyncUseCase().fold(
ifLeft = { null },
ifRight = { if (!it.isMultiCurrency || it.isLocked) null else it },
)
if (selectedWallet == null && wallets.isNotEmpty()) {
selectWalletUseCase(wallets.first().walletId)
selectedWallet = wallets.first()
}
updateDerivationNotificationState()
withContext(dispatchers.main) {
uiState = stateFactory.updateChooseWalletState(wallets, userWallets, selectedWallet)
}
}.collect()
}
private fun getInitialTokensList(searchText: String = ""): Flow<PagingData<TokenItemState>> {
return getGlobalTokenListUseCase(searchText = searchText).map {
it.map { token -> tokenConverter.convert(token) }

View file

@ -7,6 +7,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.onboarding"
}
dependencies {
/** Core modules */
implementation(project(":common"))

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.onboarding" />

View file

@ -20,6 +20,7 @@ dependencies {
implementation(deps.androidx.fragment.ktx)
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.compose)
/** Camera */
implementation(deps.camera.camera2)

View file

@ -2,6 +2,7 @@ package com.tangem.feature.qrscanning
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
@ -13,14 +14,16 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.mlkit.vision.common.InputImage
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.core.ui.theme.AppThemeModeHolder
import com.tangem.feature.qrscanning.inner.MLKitBarcodeAnalyzer
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import com.tangem.feature.qrscanning.presentation.QrScanningContent
import com.tangem.feature.qrscanning.viewmodel.QrScanningViewModel
import com.tangem.feature.qrscanning.inner.MLKitBarcodeAnalyzer
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import dagger.hilt.android.AndroidEntryPoint
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@ -49,12 +52,19 @@ internal class QrScanningFragment : ComposeFragment() {
}
private val cameraPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (!it) parentFragmentManager.popBackStack()
if (!it) viewModel.onCameraDeniedState()
}
private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) {
val selectedImage = it ?: Uri.EMPTY
if (selectedImage != Uri.EMPTY) {
val image = InputImage.fromFilePath(requireContext(), selectedImage)
analyzer.analyze(image)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.router = innerRouter
viewModel.setRouter(innerRouter, galleryLauncher)
cameraExecutor = Executors.newSingleThreadExecutor()
requestCameraPermission()
}
@ -71,7 +81,7 @@ internal class QrScanningFragment : ComposeFragment() {
QrScanningContent(
executor = { cameraExecutor },
analyzer = { analyzer },
uiState = viewModel.uiState,
uiState = viewModel.uiState.collectAsStateWithLifecycle().value,
)
}

View file

@ -0,0 +1,8 @@
package com.tangem.feature.qrscanning.presentation
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
data class CameraDeniedBottomSheetConfig(
val onGalleryClick: () -> Unit,
val onCancelClick: () -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,82 @@
package com.tangem.feature.qrscanning.presentation
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
import com.tangem.core.ui.components.SimpleSettingsRow
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.qrscanning.impl.R
@Composable
fun CameraDeniedBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(config) { content: CameraDeniedBottomSheetConfig ->
CameraDeniedBottomSheet(content = content)
}
}
@Composable
private fun CameraDeniedBottomSheet(content: CameraDeniedBottomSheetConfig) {
val context = LocalContext.current
Column {
CameraDeniedBottomSheetHeader()
SimpleSettingsRow(
title = stringResource(id = R.string.qr_scanner_camera_denied_settings_button),
icon = R.drawable.ic_settings_24,
onItemsClick = {
val intent: Intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
ContextCompat.startActivity(context, intent, null)
},
)
SimpleSettingsRow(
title = stringResource(id = R.string.qr_scanner_camera_denied_gallery_button),
icon = R.drawable.ic_gallery_24,
onItemsClick = content.onGalleryClick,
)
SimpleSettingsRow(
title = stringResource(id = R.string.common_close),
icon = R.drawable.ic_close,
onItemsClick = content.onCancelClick,
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing16),
)
}
}
@Composable
private fun CameraDeniedBottomSheetHeader() {
Text(
text = stringResource(id = R.string.qr_scanner_camera_denied_title),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing20,
end = TangemTheme.dimens.spacing20,
top = TangemTheme.dimens.spacing16,
),
)
Text(
text = stringResource(id = R.string.qr_scanner_camera_denied_text),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing20,
end = TangemTheme.dimens.spacing20,
top = TangemTheme.dimens.spacing3,
bottom = TangemTheme.dimens.spacing16,
),
)
}

View file

@ -1,9 +1,10 @@
package com.tangem.feature.qrscanning.presentation
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -18,14 +19,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import com.google.mlkit.vision.common.InputImage
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIconContent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
@ -44,15 +43,9 @@ internal fun QrScanningContent(
analyzer: () -> MLKitBarcodeAnalyzer,
uiState: QrScanningState,
) {
val context = LocalContext.current
var isFlash by remember { mutableStateOf(false) }
val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
val selectedImage = it ?: Uri.EMPTY
if (selectedImage != Uri.EMPTY) {
val image = InputImage.fromFilePath(context, selectedImage)
analyzer().analyze(image)
}
}
BackHandler(onBack = uiState.onBackClick)
Box(
modifier = Modifier
@ -101,13 +94,16 @@ internal fun QrScanningContent(
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
onClick = { galleryLauncher.launch("image/*") },
onClick = uiState.onGalleryClick,
),
tint = TangemColorPalette.White,
)
}
},
)
if (uiState.bottomSheetConfig != null) {
CameraDeniedBottomSheet(uiState.bottomSheetConfig)
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.feature.qrscanning.presentation
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
@Immutable
@ -8,5 +9,6 @@ data class QrScanningState(
val message: TextReference?,
val onQrScanned: (String) -> Unit,
val onBackClick: () -> Unit,
val onGalleryClicked: () -> Unit,
val onGalleryClick: () -> Unit,
val bottomSheetConfig: TangemBottomSheetConfig? = null,
)

View file

@ -0,0 +1,35 @@
package com.tangem.feature.qrscanning.presentation
import com.tangem.feature.qrscanning.presentation.transformers.QrScanningTransformer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class QrScanningStateController @Inject constructor() {
val uiState: StateFlow<QrScanningState> get() = mutableUiState
val value: QrScanningState get() = uiState.value
private val mutableUiState: MutableStateFlow<QrScanningState> = MutableStateFlow(value = getInitialState())
fun update(function: (QrScanningState) -> QrScanningState) {
mutableUiState.update(function = function)
}
fun update(transformer: QrScanningTransformer) {
mutableUiState.update(function = transformer::transform)
}
private fun getInitialState(): QrScanningState {
return QrScanningState(
message = null,
onBackClick = {},
onQrScanned = {},
onGalleryClick = {},
)
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.feature.qrscanning.presentation.QrScanningState
internal class DismissBottomSheetTransformer : QrScanningTransformer {
override fun transform(prevState: QrScanningState): QrScanningState {
return prevState.copy(
bottomSheetConfig = null,
)
}
}

View file

@ -1,16 +1,19 @@
package com.tangem.feature.qrscanning.presentation
package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.impl.R
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents
internal class QrScanningStateFactory(
val clickIntents: QrScanningClickIntents,
) {
internal class InitializeQrScanningStateTransformer(
private val clickIntents: QrScanningClickIntents,
private val source: SourceType,
private val network: String?,
) : QrScanningTransformer {
fun getInitialState(source: SourceType, network: String?): QrScanningState {
override fun transform(prevState: QrScanningState): QrScanningState {
val message = when (source) {
SourceType.SEND -> network?.let { resourceReference(R.string.send_qrcode_scan_info, wrappedList(it)) }
else -> null
@ -20,7 +23,7 @@ internal class QrScanningStateFactory(
message = message,
onBackClick = clickIntents::onBackClick,
onQrScanned = clickIntents::onQrScanned,
onGalleryClicked = clickIntents::onGalleryClicked,
onGalleryClick = clickIntents::onGalleryClicked,
)
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.feature.qrscanning.presentation.QrScanningState
internal interface QrScanningTransformer {
fun transform(prevState: QrScanningState): QrScanningState
}

View file

@ -0,0 +1,24 @@
package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.feature.qrscanning.presentation.CameraDeniedBottomSheetConfig
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents
internal class ShowCameraDeniedBottomSheetTransformer(
private val clickIntents: QrScanningClickIntents,
) : QrScanningTransformer {
override fun transform(prevState: QrScanningState): QrScanningState {
return prevState.copy(
bottomSheetConfig = TangemBottomSheetConfig(
isShow = true,
onDismissRequest = clickIntents::onBackClick,
content = CameraDeniedBottomSheetConfig(
onCancelClick = clickIntents::onBackClick,
onGalleryClick = clickIntents::onGalleryClicked,
),
),
)
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.feature.qrscanning.viewmodel
import androidx.activity.result.ActivityResultLauncher
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import kotlinx.coroutines.CoroutineScope
import kotlin.properties.Delegates
internal open class BaseQrScanningClickIntents {
protected val router: QrScanningInnerRouter get() = _router
protected val viewModelScope: CoroutineScope get() = _viewModelScope
protected val source: SourceType get() = _source
protected val galleryLauncher: ActivityResultLauncher<String> get() = _galleryLauncher
private var _router: QrScanningInnerRouter by Delegates.notNull()
private var _viewModelScope: CoroutineScope by Delegates.notNull()
private var _source: SourceType by Delegates.notNull()
private var _galleryLauncher: ActivityResultLauncher<String> by Delegates.notNull()
open fun initialize(
router: QrScanningInnerRouter,
source: SourceType,
galleryLauncher: ActivityResultLauncher<String>,
coroutineScope: CoroutineScope,
) {
_router = router
_viewModelScope = coroutineScope
_source = source
_galleryLauncher = galleryLauncher
}
}

View file

@ -1,5 +1,13 @@
package com.tangem.feature.qrscanning.viewmodel
import com.tangem.feature.qrscanning.presentation.QrScanningStateController
import com.tangem.feature.qrscanning.presentation.transformers.DismissBottomSheetTransformer
import com.tangem.feature.qrscanning.usecase.EmitQrScannedEventUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.launch
import javax.inject.Inject
interface QrScanningClickIntents {
fun onBackClick()
@ -7,4 +15,39 @@ interface QrScanningClickIntents {
fun onQrScanned(qrCode: String)
fun onGalleryClicked()
}
@ViewModelScoped
internal class QrScanningClickIntentsImplementor @Inject constructor(
private val stateHolder: QrScanningStateController,
private val emitQrScannedEventUseCase: EmitQrScannedEventUseCase,
private val dispatcher: CoroutineDispatcherProvider,
) : BaseQrScanningClickIntents(), QrScanningClickIntents {
private var isScanned = false
override fun onBackClick() = router.popBackStack()
override fun onQrScanned(qrCode: String) {
if (qrCode.isNotBlank()) {
if (!isScanned) {
router.popBackStack()
isScanned = true
}
viewModelScope.launch(dispatcher.main) {
emitQrScannedEventUseCase.invoke(source, qrCode)
}
}
}
override fun onGalleryClicked() {
galleryLauncher.launch(GALLERY_IMAGE_FILTER)
if (stateHolder.value.bottomSheetConfig != null) {
stateHolder.update(DismissBottomSheetTransformer())
}
}
companion object {
private const val GALLERY_IMAGE_FILTER = "image/*"
}
}

View file

@ -1,8 +1,6 @@
package com.tangem.feature.qrscanning.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -11,50 +9,38 @@ import com.tangem.feature.qrscanning.QrScanningRouter.Companion.SOURCE_KEY
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.presentation.QrScanningStateFactory
import com.tangem.feature.qrscanning.usecase.EmitQrScannedEventUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.feature.qrscanning.presentation.QrScanningStateController
import com.tangem.feature.qrscanning.presentation.transformers.ShowCameraDeniedBottomSheetTransformer
import com.tangem.feature.qrscanning.presentation.transformers.InitializeQrScanningStateTransformer
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import kotlin.properties.Delegates
@HiltViewModel
internal class QrScanningViewModel @Inject constructor(
private val emitQrScannedEventUseCase: EmitQrScannedEventUseCase,
private val dispatcher: CoroutineDispatcherProvider,
private val stateHolder: QrScanningStateController,
private val clickIntents: QrScanningClickIntentsImplementor,
savedStateHandle: SavedStateHandle,
) : ViewModel(), QrScanningClickIntents {
) : ViewModel() {
private val source: SourceType = savedStateHandle[SOURCE_KEY] ?: error("Source is mandatory")
private val network: String? = savedStateHandle[NETWORK_KEY]
private val factory = QrScanningStateFactory(
clickIntents = this,
)
val uiState: StateFlow<QrScanningState> = stateHolder.uiState
var router: QrScanningInnerRouter by Delegates.notNull()
var uiState: QrScanningState by mutableStateOf(factory.getInitialState(source, network))
private set
private var isScanned = false
override fun onBackClick() = router.popBackStack()
override fun onQrScanned(qrCode: String) {
if (qrCode.isNotBlank()) {
if (!isScanned) {
router.popBackStack()
isScanned = true
}
viewModelScope.launch(dispatcher.main) {
emitQrScannedEventUseCase.invoke(source, qrCode)
}
}
fun setRouter(router: QrScanningInnerRouter, galleryLauncher: ActivityResultLauncher<String>) {
clickIntents.initialize(
router = router,
source = source,
galleryLauncher = galleryLauncher,
coroutineScope = viewModelScope,
)
stateHolder.update(InitializeQrScanningStateTransformer(clickIntents, source, network))
}
override fun onGalleryClicked() {
// [REDACTED_JIRA]
fun onQrScanned(qrCode: String) = clickIntents.onQrScanned(qrCode)
fun onCameraDeniedState() {
stateHolder.update(ShowCameraDeniedBottomSheetTransformer(clickIntents))
}
}

View file

@ -6,6 +6,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.referral.data"
}
dependencies {
/** Project */

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.referral.data" />

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.referral.domain" />

View file

@ -6,6 +6,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.referral.presentation"
}
dependencies {
/** Core modules */
implementation(project(":core:analytics"))

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.referral.presentation" />

View file

@ -73,7 +73,7 @@ internal class SendFragment : ComposeFragment() {
SystemBarsEffect {
setSystemBarsColor(systemBarsColor)
}
SendScreen(viewModel.uiState)
SendScreen(viewModel.uiState, viewModel.stateRouter.currentState)
}
override fun onDestroy() {

View file

@ -23,9 +23,6 @@ internal class SendOnNextScreenAnalyticSender(
}
analyticsEventHandler.send(SendAnalyticEvents.SelectedFee(selectedFee.name))
}
if (feeState.isSubtract) {
analyticsEventHandler.send(SendAnalyticEvents.SubtractFromAmount)
}
}
SendUiStateType.Amount -> {
val isFiatSelected = state.amountState?.amountTextField?.isFiatValue ?: return

View file

@ -1,23 +1,14 @@
package com.tangem.features.send.impl.presentation.domain
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.PersistentList
@Immutable
internal sealed class SendRecipientListContent {
data class Item(
val id: String,
val title: TextReference,
val subtitle: TextReference,
val timestamp: TextReference? = null,
val subtitleEndOffset: Int = 0,
@DrawableRes val subtitleIconRes: Int? = null,
) : SendRecipientListContent()
data class Wallets(
val list: PersistentList<Item>,
val isWalletsOnly: Boolean,
) : SendRecipientListContent()
}
data class SendRecipientListContent(
val id: String,
val title: TextReference,
val subtitle: TextReference,
val timestamp: TextReference? = null,
val subtitleEndOffset: Int = 0,
@DrawableRes val subtitleIconRes: Int? = null,
val isVisible: Boolean = true,
)

View file

@ -20,7 +20,7 @@ internal sealed class SendAlertState {
) : SendAlertState() {
override val message: TextReference = resourceReference(R.string.common_unknown_error)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.send_alert_button_request_support)
resourceReference(id = R.string.common_support)
}
data class TransactionError(
@ -35,7 +35,7 @@ internal sealed class SendAlertState {
formatArgs = wrappedList(causeTextReference ?: cause.orEmpty(), code),
)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.send_alert_button_request_support)
resourceReference(id = R.string.common_support)
}
data class DemoMode(
@ -45,11 +45,28 @@ internal sealed class SendAlertState {
override val message: TextReference = resourceReference(id = R.string.warning_demo_mode_message)
}
object FeeIncreased : SendAlertState() {
data object FeeIncreased : SendAlertState() {
override val title: TextReference? = null
override val message: TextReference = resourceReference(id = R.string.send_notification_high_fee_title)
}
data class FeeTooLow(
override val onConfirmClick: () -> Unit,
) : SendAlertState() {
override val title: TextReference? = null
override val message: TextReference = resourceReference(id = R.string.send_alert_fee_too_low_text)
override val confirmButtonText: TextReference = resourceReference(R.string.common_continue)
}
data class FeeCoverage(
override val onConfirmClick: (() -> Unit),
) : SendAlertState() {
override val title: TextReference? = null
override val message: TextReference = resourceReference(id = R.string.send_alert_fee_coverage_title)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.send_alert_fee_coverage_subract_text)
}
data class ReserveAmount(val amount: String) : SendAlertState() {
override val title: TextReference =
resourceReference(id = R.string.send_notification_invalid_reserve_amount_title, wrappedList(amount))

View file

@ -3,6 +3,7 @@ package com.tangem.features.send.impl.presentation.state
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeStateFactory
@ -43,6 +44,20 @@ internal class SendEventStateFactory(
)
}
fun getFeeCoverageAlert(onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
return state.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertState.FeeCoverage(
onConfirmClick = clickIntents::onSubtractSelect,
),
),
onConsume = onConsume,
),
)
}
fun getFeeUpdatedAlert(fee: TransactionFee, onConsume: () -> Unit, onFeeNotIncreased: () -> Unit): SendUiState {
val state = currentStateProvider()
val feeSelector = state.feeState?.feeSelectorState as? FeeSelectorState.Content ?: return state
@ -74,6 +89,29 @@ internal class SendEventStateFactory(
}
}
fun getFeeTooLowAlert(onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
val feeSelectorState = state.feeState?.feeSelectorState as? FeeSelectorState.Content ?: return state
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return state
val minimumValue = multipleFees.minimum.amount.value ?: return state
val customAmount = feeSelectorState.customValues.firstOrNull() ?: return state
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
val isFeeTooLow = feeSelectorState.selectedFee == FeeType.Custom && minimumValue > customValue
if (!isFeeTooLow) return state
return state.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertState.FeeTooLow(
onConfirmClick = clickIntents::showSend,
),
),
onConsume = onConsume,
),
)
}
fun getGenericErrorState(error: Throwable? = null, onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
return state.copy(

View file

@ -12,12 +12,14 @@ internal sealed class SendNotification(val config: NotificationConfig) {
title: TextReference,
subtitle: TextReference,
buttonState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : SendNotification(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = R.drawable.ic_alert_24,
buttonsState = buttonState,
onCloseClick = onCloseClick,
),
) {
@ -45,12 +47,17 @@ internal sealed class SendNotification(val config: NotificationConfig) {
val cryptoCurrency: String,
val utxoLimit: String,
val amountLimit: String,
val onConfirmClick: () -> Unit,
) : Error(
title = resourceReference(R.string.send_notifiaction_transaction_limit_title),
subtitle = resourceReference(
R.string.send_notifiaction_transaction_limit_text,
R.string.send_notification_transaction_limit_text,
wrappedList(cryptoCurrency, utxoLimit, amountLimit),
),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_to, wrappedList(amountLimit)),
onClick = onConfirmClick,
),
)
}
@ -58,32 +65,49 @@ internal sealed class SendNotification(val config: NotificationConfig) {
title: TextReference,
subtitle: TextReference,
buttonsState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : SendNotification(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = R.drawable.img_attention_20,
buttonsState = buttonsState,
onCloseClick = onCloseClick,
),
) {
data class HighFeeError(
val amount: String,
val onConfirmClick: () -> Unit,
val onDismissClick: () -> Unit,
val onCloseClick: () -> Unit,
) : Warning(
title = resourceReference(R.string.send_notification_high_fee_title),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(amount)),
buttonsState = NotificationConfig.ButtonsState.PairButtonsConfig(
primaryText = resourceReference(R.string.send_notification_fee_too_high_accept, wrappedList(amount)),
onPrimaryClick = onConfirmClick,
secondaryText = resourceReference(R.string.send_notification_fee_too_high_ignore),
onSecondaryClick = onDismissClick,
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_by, wrappedList(amount)),
onClick = onConfirmClick,
),
onCloseClick = onCloseClick,
)
data class ExistentialDeposit(val deposit: String) : Warning(
title = resourceReference(R.string.send_notification_existential_deposit_title),
subtitle = resourceReference(R.string.send_notification_existential_deposit_text, wrappedList(deposit)),
)
data class NetworkCoverage(
val amountReducedBy: String,
val amountReduced: String,
) : Warning(
title = resourceReference(id = R.string.send_network_fee_warning_title),
subtitle = resourceReference(
id = R.string.send_network_fee_warning_content,
formatArgs = wrappedList(amountReducedBy, amountReduced),
),
)
data object FeeTooLow : Warning(
title = resourceReference(id = R.string.send_notification_transaction_delay_title),
subtitle = resourceReference(id = R.string.send_notification_transaction_delay_text),
)
}
}

View file

@ -1,11 +1,15 @@
package com.tangem.features.send.impl.presentation.state
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.isNullOrZero
@ -17,40 +21,47 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class SendNotificationFactory(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val currentStateProvider: Provider<SendUiState>,
private val userWalletProvider: Provider<UserWallet>,
private val currencyChecksRepository: CurrencyChecksRepository,
private val stateRouterProvider: Provider<StateRouter>,
private val clickIntents: SendClickIntents,
) {
fun create(): Flow<ImmutableList<SendNotification>> = currentStateProvider().currentState
.filter { it == SendUiStateType.Send }
fun create(): Flow<ImmutableList<SendNotification>> = stateRouterProvider().currentState
.filter { it.type == SendUiStateType.Send }
.map {
val state = currentStateProvider()
val sendState = state.sendState
val feeState = state.feeState ?: return@map persistentListOf()
val feeAmount = feeState.fee?.amount?.value ?: BigDecimal.ZERO
val amountValue = state.amountState?.amountTextField?.value?.toBigDecimalOrNull() ?: BigDecimal.ZERO
val sendAmount = if (feeState.isSubtract) feeState.receivedAmountValue else amountValue
val amountValue = state.amountState?.amountTextField?.cryptoAmount?.value ?: BigDecimal.ZERO
val sendAmount = if (sendState.isSubtract) amountValue.minus(feeAmount) else amountValue
buildList {
// errors
addExceedBalanceNotification(feeAmount, sendAmount)
addInvalidAmountNotification(feeState.isSubtract, sendAmount)
addInvalidAmountNotification(sendState.isSubtract, sendAmount)
addMinimumAmountErrorNotification(feeAmount, sendAmount)
addDustWarningNotification(feeAmount, sendAmount)
addTransactionLimitErrorNotification(feeAmount, sendAmount)
// warnings
addFeeCoverageNotification(sendState.isSubtract, sendAmount)
addExistentialWarningNotification(feeAmount, sendAmount)
addHighFeeWarningNotification(amountValue, state.sendState.ignoreAmountReduce)
addHighFeeWarningNotification(sendAmount, sendState.ignoreAmountReduce)
addTooLowNotification(feeState)
}.toImmutableList()
}
fun dismissHighFeeWarningState(): SendUiState {
fun dismissNotificationState(clazz: Class<out SendNotification>): SendUiState {
val state = currentStateProvider()
val sendState = state.sendState
val updatedNotifications = sendState.notifications.filterNot { it is SendNotification.Warning.HighFeeError }
val notificationsToRemove = sendState.notifications.filterIsInstance(clazz)
val updatedNotifications = sendState.notifications.toMutableList()
updatedNotifications.removeAll(notificationsToRemove)
return state.copy(
sendState = sendState.copy(
ignoreAmountReduce = true,
@ -158,6 +169,13 @@ internal class SendNotificationFactory(
cryptoAmount = utxoLimit.maxAmount,
cryptoCurrency = cryptoCurrency,
),
onConfirmClick = {
val reduceTo = utxoLimit.maxAmount.toPlainString()
clickIntents.onAmountReduceClick(
reduceTo,
SendNotification.Error.TransactionLimitError::class.java,
)
},
),
)
}
@ -203,9 +221,11 @@ internal class SendNotificationFactory(
amount = TEZOS_FEE_THRESHOLD.toPlainString(),
onConfirmClick = {
val reduceTo = sendAmount.minus(TEZOS_FEE_THRESHOLD).toPlainString()
clickIntents.onAmountReduceClick(reduceTo)
clickIntents.onAmountReduceClick(reduceTo, SendNotification.Warning.HighFeeError::class.java)
},
onCloseClick = {
clickIntents.onNotificationCancel(SendNotification.Warning.HighFeeError::class.java)
},
onDismissClick = clickIntents::onAmountReduceIgnoreClick,
),
)
}
@ -234,6 +254,40 @@ internal class SendNotificationFactory(
}
}
private fun MutableList<SendNotification>.addFeeCoverageNotification(
isSubtract: Boolean,
amountValue: BigDecimal,
) {
val state = currentStateProvider()
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
val feeAmount = state.feeState?.fee?.amount?.value ?: BigDecimal.ZERO
val amountReducedValue = amountValue.minus(feeAmount)
val amountReducedByValue = amountValue.minus(amountReducedValue)
val amountReducedBy = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = amountReducedByValue,
cryptoCurrency = cryptoCurrency,
)
val amountReduced = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = amountReducedValue,
cryptoCurrency = cryptoCurrency,
)
if (isSubtract) {
add(SendNotification.Warning.NetworkCoverage(amountReducedBy, amountReduced))
}
}
private fun MutableList<SendNotification>.addTooLowNotification(feeState: SendStates.FeeState) {
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return
val minimumValue = multipleFees.minimum.amount.value ?: return
val customAmount = feeSelectorState.customValues.firstOrNull() ?: return
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
if (feeSelectorState.selectedFee == FeeType.Custom && minimumValue > customValue) {
add(SendNotification.Warning.FeeTooLow)
}
}
companion object {
private const val CARDANO_MINIMUM = "1"
private const val DOGECOIN_MINIMUM = "0.01"

View file

@ -1,6 +1,5 @@
package com.tangem.features.send.impl.presentation.state
import androidx.paging.PagingData
import arrow.core.getOrElse
import com.tangem.blockchain.common.TransactionData
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
@ -23,7 +22,6 @@ import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import timber.log.Timber
@Suppress("LongParameterList")
@ -78,7 +76,6 @@ internal class SendStateFactory(
// region UI states
fun getInitialState(): SendUiState = SendUiState(
clickIntents = clickIntents,
currentState = MutableStateFlow(SendUiStateType.None),
event = consumedEvent(),
isEditingDisabled = false,
isBalanceHidden = false,
@ -109,17 +106,11 @@ internal class SendStateFactory(
//endregion
//region recipient
fun onLoadedRecipientList(
wallets: List<AvailableWallet?>,
txHistory: PagingData<TxHistoryItem>,
txHistoryCount: Int,
) {
fun onLoadedRecipientList(wallets: List<AvailableWallet?>, txHistory: List<TxHistoryItem>): SendUiState =
recipientListStateConverter.convert(
wallets = wallets,
txHistory = txHistory,
txHistoryCount = txHistoryCount,
)
}
fun onRecipientAddressValueChange(value: String, isXAddress: Boolean = false): SendUiState {
val state = currentStateProvider()
@ -223,6 +214,13 @@ internal class SendStateFactory(
//endregion
//region send
fun onSubtractSelect(isSubtract: Boolean): SendUiState {
val state = currentStateProvider()
return state.copy(
sendState = state.sendState.copy(isSubtract = isSubtract),
)
}
fun getSendingStateUpdate(isSending: Boolean): SendUiState {
val state = currentStateProvider()
return state.copy(sendState = state.sendState.copy(isSending = isSending))

View file

@ -2,7 +2,6 @@ package com.tangem.features.send.impl.presentation.state
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.paging.PagingData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
import com.tangem.core.ui.event.StateEvent
@ -17,8 +16,6 @@ import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.math.BigDecimal
/**
@ -32,8 +29,6 @@ internal data class SendUiState(
val recipientState: SendStates.RecipientState? = null,
val feeState: SendStates.FeeState? = null,
val sendState: SendStates.SendState = SendStates.SendState(),
val recipientList: MutableStateFlow<PagingData<SendRecipientListContent>> = MutableStateFlow(PagingData.empty()),
val currentState: StateFlow<SendUiStateType>,
val isBalanceHidden: Boolean,
val event: StateEvent<SendEvent>,
)
@ -64,7 +59,8 @@ internal sealed class SendStates {
override val isPrimaryButtonEnabled: Boolean,
val addressTextField: SendTextField.RecipientAddress,
val memoTextField: SendTextField.RecipientMemo?,
val recipients: MutableStateFlow<PagingData<SendRecipientListContent>> = MutableStateFlow(PagingData.empty()),
val recent: ImmutableList<SendRecipientListContent>,
val wallets: ImmutableList<SendRecipientListContent>,
val network: String,
val isValidating: Boolean = false,
) : SendStates()
@ -75,12 +71,7 @@ internal sealed class SendStates {
override val type: SendUiStateType = SendUiStateType.Fee,
override val isPrimaryButtonEnabled: Boolean = false,
val feeSelectorState: FeeSelectorState,
val isSubtractAvailable: Boolean,
val isSubtract: Boolean,
val isUserSubtracted: Boolean,
val fee: Fee?,
val receivedAmountValue: BigDecimal,
val receivedAmount: String,
val rate: BigDecimal?,
val appCurrency: AppCurrency,
val isFeeApproximate: Boolean,
@ -94,13 +85,20 @@ internal sealed class SendStates {
override val isPrimaryButtonEnabled: Boolean = true,
val isSending: Boolean = false,
val isSuccess: Boolean = false,
val isSubtract: Boolean = false,
val transactionDate: Long = 0L,
val txUrl: String = "",
val ignoreAmountReduce: Boolean = false,
val isFromConfirmation: Boolean = true,
val notifications: ImmutableList<SendNotification> = persistentListOf(),
) : SendStates()
}
data class SendUiCurrentScreen(
val type: SendUiStateType,
val isFromConfirmation: Boolean,
)
enum class SendUiStateType {
None,
Amount,

View file

@ -14,58 +14,58 @@ internal class StateRouter(
private val analyticsEventsHandler: AnalyticsEventHandler,
private val isEditingDisabled: Boolean,
) {
private var mutableCurrentState: MutableStateFlow<SendUiStateType> = MutableStateFlow(
if (isEditingDisabled) {
SendUiStateType.None
} else {
SendUiStateType.Recipient
},
)
private var mutableCurrentState: MutableStateFlow<SendUiCurrentScreen> = MutableStateFlow(getInitState())
val currentState: StateFlow<SendUiStateType> = mutableCurrentState
val currentState: StateFlow<SendUiCurrentScreen>
get() = mutableCurrentState
fun clear() {
mutableCurrentState.update { getInitState() }
}
fun popBackStack() {
fragmentManager.get()?.popBackStack()
}
fun onBackClick(isSuccess: Boolean = false) {
val type = currentState.value.type
when {
isSuccess -> popBackStack()
isEditingDisabled -> when (currentState.value) {
isEditingDisabled -> when (type) {
SendUiStateType.Send -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee))
showFee()
}
else -> popBackStack()
}
else -> when (currentState.value) {
else -> when (type) {
SendUiStateType.Amount -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Address))
showRecipient()
continueToSend(::showRecipient)
}
SendUiStateType.Fee -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Amount))
showAmount()
continueToSend(::showAmount)
}
SendUiStateType.Send -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee))
showFee()
continueToSend(::showFee)
}
else -> popBackStack()
else -> continueToSend(::popBackStack)
}
}
}
fun onNextClick(): SendUiStateType {
val prevState = currentState.value
when (currentState.value) {
val prevState = currentState.value.type
when (currentState.value.type) {
SendUiStateType.Recipient -> {
analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Amount))
showAmount()
continueToSend(::showAmount)
}
SendUiStateType.Amount -> {
analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Fee))
showFee()
continueToSend(::showFee)
}
SendUiStateType.Fee -> {
analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Fee))
@ -83,7 +83,7 @@ internal class StateRouter(
if (isEditingDisabled) {
popBackStack()
} else {
when (currentState.value) {
when (currentState.value.type) {
SendUiStateType.Amount -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Amount))
showRecipient()
@ -97,23 +97,39 @@ internal class StateRouter(
}
}
fun showAmount() {
fun showAmount(isFromConfirmation: Boolean = false) {
analyticsEventsHandler.send(SendAnalyticEvents.AmountScreenOpened)
mutableCurrentState.update { SendUiStateType.Amount }
mutableCurrentState.update { SendUiCurrentScreen(SendUiStateType.Amount, isFromConfirmation) }
}
fun showRecipient() {
fun showRecipient(isFromConfirmation: Boolean = false) {
analyticsEventsHandler.send(SendAnalyticEvents.AddressScreenOpened)
mutableCurrentState.update { SendUiStateType.Recipient }
mutableCurrentState.update { SendUiCurrentScreen(SendUiStateType.Recipient, isFromConfirmation) }
}
fun showFee() {
fun showFee(isFromConfirmation: Boolean = false) {
analyticsEventsHandler.send(SendAnalyticEvents.FeeScreenOpened)
mutableCurrentState.update { SendUiStateType.Fee }
mutableCurrentState.update { SendUiCurrentScreen(SendUiStateType.Fee, isFromConfirmation) }
}
private fun showSend() {
fun showSend() {
analyticsEventsHandler.send(SendAnalyticEvents.ConfirmationScreenOpened)
mutableCurrentState.update { SendUiStateType.Send }
mutableCurrentState.update { SendUiCurrentScreen(SendUiStateType.Send, isFromConfirmation = false) }
}
private fun continueToSend(show: () -> Unit) {
if (currentState.value.isFromConfirmation) showSend() else show()
}
private fun getInitState() = if (isEditingDisabled) {
SendUiCurrentScreen(
type = SendUiStateType.None,
isFromConfirmation = false,
)
} else {
SendUiCurrentScreen(
type = SendUiStateType.Recipient,
isFromConfirmation = false,
)
}
}

View file

@ -1,14 +1,14 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import java.math.BigDecimal
/**
* Calculate receiving amount when fee is subtracted from sending amount
* Check if sending amount with fee is greater than balance
*/
internal fun calculateReceiveAmount(state: SendUiState, feeAmount: Fee): BigDecimal {
val amountValue = state.amountState?.amountTextField?.cryptoAmount?.value ?: BigDecimal.ZERO
val fee = feeAmount.amount.value ?: return BigDecimal.ZERO
return amountValue.minus(fee)
internal fun checkFeeCoverage(state: SendUiState, cryptoCurrencyStatus: CryptoCurrencyStatus): Boolean {
val balance = cryptoCurrencyStatus.value.amount ?: return false
val fee = state.feeState?.fee?.amount?.value ?: return false
val amount = state.amountState?.amountTextField?.cryptoAmount?.value ?: return false
return balance <= amount + fee
}

View file

@ -1,17 +1,19 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
@ -22,17 +24,19 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class FeeNotificationFactory(
private val coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val currentStateProvider: Provider<SendUiState>,
private val userWalletProvider: Provider<UserWallet>,
private val stateRouterProvider: Provider<StateRouter>,
private val clickIntents: SendClickIntents,
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
) {
fun create() = currentStateProvider().currentState
.filter { it == SendUiStateType.Fee }
fun create() = stateRouterProvider().currentState
.filter { it.type == SendUiStateType.Fee }
.map {
val state = currentStateProvider()
val feeState = state.feeState ?: return@map persistentListOf()
@ -45,9 +49,7 @@ internal class FeeNotificationFactory(
is FeeSelectorState.Content -> {
val customFee = feeSelectorState.customValues
val selectedFee = feeSelectorState.selectedFee
addTooLowNotification(feeSelectorState.fees, selectedFee, customFee)
addTooHighNotification(feeSelectorState.fees, selectedFee, customFee)
addFeeCoverageNotification(feeState, state.amountState)
addExceedsBalanceNotification(feeState.fee)
}
}
@ -60,20 +62,6 @@ internal class FeeNotificationFactory(
}
}
private fun MutableList<SendFeeNotification>.addTooLowNotification(
transactionFee: TransactionFee,
selectedFee: FeeType,
customFee: List<SendTextField.CustomFee>,
) {
val multipleFees = transactionFee as? TransactionFee.Choosable ?: return
val minimumValue = multipleFees.minimum.amount.value ?: return
val customAmount = customFee.firstOrNull() ?: return
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
if (selectedFee == FeeType.Custom && minimumValue > customValue) {
add(SendFeeNotification.Warning.TooLow)
}
}
private fun MutableList<SendFeeNotification>.addTooHighNotification(
transactionFee: TransactionFee,
selectedFee: FeeType,
@ -89,20 +77,6 @@ internal class FeeNotificationFactory(
}
}
private fun MutableList<SendFeeNotification>.addFeeCoverageNotification(
feeState: SendStates.FeeState,
amountState: SendStates.AmountState?,
) {
if (!feeState.isSubtractAvailable) return
val cryptoAmount = coinCryptoCurrencyStatusProvider().value.amount ?: return
val feeValue = feeState.fee?.amount?.value ?: return
val value = amountState?.amountTextField?.cryptoAmount?.value ?: return
if (cryptoAmount <= value + feeValue && feeState.isSubtract && !feeState.isUserSubtracted) {
add(SendFeeNotification.Warning.NetworkCoverage)
}
}
private suspend fun MutableList<SendFeeNotification>.addExceedsBalanceNotification(fee: Fee?) {
val feeValue = fee?.amount?.value ?: BigDecimal.ZERO
val userWalletId = userWalletProvider().walletId
@ -118,6 +92,7 @@ internal class FeeNotificationFactory(
ifRight = { it },
) ?: return
val mergeFeeNetworkName = cryptoCurrencyStatus.shouldMergeFeeNetworkName()
when (warning) {
is CryptoCurrencyWarning.BalanceNotEnoughForFee -> {
add(
@ -127,6 +102,7 @@ internal class FeeNotificationFactory(
currencyName = cryptoCurrencyStatus.currency.name,
feeName = warning.coinCurrency.name,
feeSymbol = warning.coinCurrency.symbol,
mergeFeeNetworkName = mergeFeeNetworkName,
onClick = {
clickIntents.onTokenDetailsClick(
userWalletId = userWalletId,
@ -145,6 +121,7 @@ internal class FeeNotificationFactory(
feeName = warning.feeCurrencyName,
feeSymbol = warning.feeCurrencySymbol,
networkName = warning.networkName,
mergeFeeNetworkName = mergeFeeNetworkName,
onClick = currency?.let {
{
clickIntents.onTokenDetailsClick(
@ -160,6 +137,11 @@ internal class FeeNotificationFactory(
}
}
// workaround for networks that users have misunderstanding
private fun CryptoCurrencyStatus.shouldMergeFeeNetworkName(): Boolean {
return Blockchain.fromNetworkId(this.currency.network.backendId) == Blockchain.Arbitrum
}
companion object {
private val FEE_MAX_DIFF = BigDecimal(5)
private const val HIGH_FEE_DIFF_DECIMALS = 0

View file

@ -3,7 +3,6 @@ package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.extensions.isZero
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
@ -14,7 +13,6 @@ import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
/**
* Factory to produce fee state for [SendUiState]
@ -22,7 +20,6 @@ import java.math.BigDecimal
internal class FeeStateFactory(
private val clickIntents: SendClickIntents,
private val currentStateProvider: Provider<SendUiState>,
private val coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val isFeeApproximateUseCase: IsFeeApproximateUseCase,
@ -55,9 +52,8 @@ internal class FeeStateFactory(
)
}
fun onFeeOnLoadedState(fees: TransactionFee, isSubtractAvailable: Boolean): SendUiState {
fun onFeeOnLoadedState(fees: TransactionFee): SendUiState {
val state = currentStateProvider()
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val feeState = state.feeState ?: return state
val feeSelectorState = (feeState.feeSelectorState as? FeeSelectorState.Content)?.copy(
fees = fees,
@ -68,43 +64,15 @@ internal class FeeStateFactory(
)
val fee = feeConverter.convert(feeSelectorState)
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
isSubtractAvailable = isSubtractAvailable,
feeSelectorState = feeSelectorState,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = isSubtractAvailable && checkAutoSubtract(state, fee, balance),
isFeeApproximate = isFeeApproximate(fee),
),
)
}
fun onFeeOnLoadedState(fees: TransactionFee): SendUiState {
val state = currentStateProvider()
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val updatedFeeSelector = feeSelectorState.copy(
fees = fees,
customValues = customFeeFieldConverter.convert(fees.normal),
)
val fee = feeConverter.convert(updatedFeeSelector)
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
feeSelectorState = updatedFeeSelector,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = checkAutoSubtract(state, fee, balance),
),
)
}
fun onFeeOnErrorState(): SendUiState {
val state = currentStateProvider()
return state.copy(
@ -118,18 +86,13 @@ internal class FeeStateFactory(
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val updatedFeeSelectorState = feeSelectorState.copy(selectedFee = feeType)
val fee = feeConverter.convert(updatedFeeSelectorState)
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
fee = fee,
feeSelectorState = updatedFeeSelectorState,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = checkAutoSubtract(state, fee, balance),
),
)
}
@ -139,34 +102,12 @@ internal class FeeStateFactory(
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val updatedFeeSelectorState = customFeeFieldConverter.onValueChange(feeSelectorState, index, value)
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val fee = feeConverter.convert(updatedFeeSelectorState)
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
feeSelectorState = updatedFeeSelectorState,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = checkAutoSubtract(state, fee, balance),
),
)
}
fun onSubtractSelect(value: Boolean): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val fee = feeConverter.convert(feeSelectorState)
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
isSubtract = value,
isUserSubtracted = true,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
fee = fee,
),
)
}
@ -187,9 +128,6 @@ internal class FeeStateFactory(
): Boolean {
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return false
val customValue = feeSelectorState.customValues.firstOrNull()
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val fee = feeConverter.convert(feeSelectorState)
val feeValue = fee.amount.value ?: BigDecimal.ZERO
val isNotCustom = feeSelectorState.selectedFee != FeeType.Custom
val isNotEmptyCustom = if (customValue != null) {
@ -198,24 +136,8 @@ internal class FeeStateFactory(
false
}
val noErrors = notifications.none { it is SendFeeNotification.Error }
val isSubtractRequired = when {
!feeState.isSubtractAvailable -> true // current currency is not fee currency
feeValue + feeState.receivedAmountValue >= balance -> feeState.isSubtract
else -> feeValue + feeState.receivedAmountValue <= balance
}
return noErrors && isSubtractRequired && (isNotEmptyCustom || isNotCustom)
}
private fun checkAutoSubtract(state: SendUiState, fee: Fee, balance: BigDecimal): Boolean {
val feeState = state.feeState ?: return false
val amountValue = state.amountState?.amountTextField?.cryptoAmount?.value ?: BigDecimal.ZERO
val feeAmount = fee.amount.value ?: BigDecimal.ZERO
return if (feeState.isUserSubtracted) {
feeState.isSubtract
} else {
amountValue + feeAmount >= balance
}
return noErrors && (isNotEmptyCustom || isNotCustom)
}
private fun isFeeApproximate(fee: Fee): Boolean {
@ -225,13 +147,4 @@ internal class FeeStateFactory(
amountType = fee.amount.type,
)
}
private fun getFormattedValue(value: BigDecimal): String {
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
return BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = value,
cryptoCurrency = cryptoCurrency.symbol,
decimals = cryptoCurrency.decimals,
)
}
}

View file

@ -20,11 +20,6 @@ sealed class SendFeeNotification(val config: NotificationConfig) {
buttonsState = buttonsState,
),
) {
object TooLow : Warning(
title = resourceReference(id = R.string.send_notification_transaction_delay_title),
subtitle = resourceReference(id = R.string.send_notification_transaction_delay_text),
)
data class TooHigh(
val value: String,
) : Warning(
@ -32,11 +27,6 @@ sealed class SendFeeNotification(val config: NotificationConfig) {
subtitle = resourceReference(id = R.string.send_notification_fee_too_high_text, wrappedList(value)),
)
object NetworkCoverage : Warning(
title = resourceReference(id = R.string.send_network_fee_warning_title),
subtitle = resourceReference(id = R.string.send_network_fee_warning_content),
)
data class NetworkFeeUnreachable(val onRefresh: () -> Unit) : Warning(
title = resourceReference(R.string.send_fee_unreachable_error_title),
subtitle = resourceReference(R.string.send_fee_unreachable_error_text),
@ -66,6 +56,7 @@ sealed class SendFeeNotification(val config: NotificationConfig) {
val feeName: String,
val feeSymbol: String,
val networkName: String,
val mergeFeeNetworkName: Boolean = false,
val onClick: (() -> Unit)? = null,
) : Error(
title = resourceReference(
@ -79,7 +70,16 @@ sealed class SendFeeNotification(val config: NotificationConfig) {
iconResId = networkIconId,
buttonsState = onClick?.let {
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_buy_currency, wrappedList(feeName)),
text = resourceReference(
R.string.common_buy_currency,
wrappedList(
if (mergeFeeNetworkName) {
"$currencyName ($feeSymbol)"
} else {
feeName
},
),
),
onClick = onClick,
)
},

View file

@ -6,7 +6,6 @@ import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
internal class SendFeeStateConverter(
private val appCurrencyProvider: Provider<AppCurrency>,
@ -17,12 +16,7 @@ internal class SendFeeStateConverter(
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
return SendStates.FeeState(
feeSelectorState = FeeSelectorState.Loading,
isSubtractAvailable = false,
isSubtract = false,
isUserSubtracted = false,
fee = null,
receivedAmountValue = BigDecimal.ZERO,
receivedAmount = "",
notifications = persistentListOf(),
rate = cryptoCurrencyStatus.value.fiatRate,
appCurrency = appCurrencyProvider(),

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.extensions.isZero
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
@ -31,9 +32,10 @@ internal class EthereumCustomFeeConverter(
) : Converter<Fee.Ethereum, ImmutableList<SendTextField.CustomFee>> {
override fun convert(value: Fee.Ethereum): ImmutableList<SendTextField.CustomFee> {
val feeValue = value.amount.value
return persistentListOf(
SendTextField.CustomFee(
value = value.amount.value?.parseBigDecimal(value.amount.decimals).orEmpty(),
value = feeValue?.parseBigDecimal(value.amount.decimals).orEmpty(),
decimals = value.amount.decimals,
symbol = value.amount.currencySymbol,
onValueChange = { clickIntents.onCustomFeeValueChange(FEE_AMOUNT, it) },
@ -43,7 +45,7 @@ internal class EthereumCustomFeeConverter(
),
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_max_fee_footer),
label = getFeeFormatted(value.amount.value),
label = getFeeFormatted(feeValue),
keyboardActions = KeyboardActions(),
),
SendTextField.CustomFee(
@ -67,7 +69,7 @@ internal class EthereumCustomFeeConverter(
footer = resourceReference(R.string.send_gas_limit_footer),
onValueChange = { clickIntents.onCustomFeeValueChange(GAS_LIMIT, it) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
imeAction = if (checkExceedBalance(feeValue)) ImeAction.None else ImeAction.Done,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(onDone = { clickIntents.onNextClick() }),
@ -133,7 +135,16 @@ internal class EthereumCustomFeeConverter(
label = getFeeFormatted(newFeeAmount),
),
)
set(index, this[index].copy(value = value))
set(
index,
this[index].copy(
value = value,
keyboardOptions = KeyboardOptions(
imeAction = if (!checkExceedBalance(newFeeAmount)) ImeAction.None else ImeAction.Done,
keyboardType = KeyboardType.Number,
),
),
)
}
}
}.toImmutableList()
@ -152,6 +163,13 @@ internal class EthereumCustomFeeConverter(
)
}
private fun checkExceedBalance(feeAmount: BigDecimal?): Boolean {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val currencyCryptoAmount = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
return feeAmount == null || feeAmount.isZero() || feeAmount > currencyCryptoAmount
}
companion object {
private const val ETHEREUM_GAS_UNIT = "GWEI"
private const val ETHEREUM_GAS_DECIMALS = 18

View file

@ -1,9 +1,11 @@
package com.tangem.features.send.impl.presentation.state.fields
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.extensions.isZero
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.utils.Provider
@ -19,7 +21,6 @@ internal class SendAmountFieldChangeConverter(
val state = currentStateProvider()
val amountState = state.amountState ?: return state
val amountTextField = amountState.amountTextField
val feeState = state.feeState ?: return state
if (value.isEmpty()) return state.emptyState()
val cryptoDecimals = amountTextField.cryptoAmount.decimals
@ -33,21 +34,22 @@ internal class SendAmountFieldChangeConverter(
val checkValue = if (amountTextField.isFiatValue) fiatValue else cryptoValue
val isExceedBalance = checkValue.checkExceedBalance(amountTextField)
val isMaxAmount = checkValue.checkMaxAmount(amountTextField)
val isZero = if (amountTextField.isFiatValue) decimalFiatValue.isZero() else decimalCryptoValue.isZero()
return state.copy(
amountState = amountState.copy(
isPrimaryButtonEnabled = !isExceedBalance,
isPrimaryButtonEnabled = !isExceedBalance && !isZero,
amountTextField = amountTextField.copy(
value = cryptoValue,
fiatValue = fiatValue,
isError = isExceedBalance,
cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue),
fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue),
keyboardOptions = KeyboardOptions(
imeAction = if (!isExceedBalance) ImeAction.Done else ImeAction.None,
keyboardType = KeyboardType.Number,
),
),
),
feeState = feeState.copy(
isSubtract = isMaxAmount,
),
)
}
@ -92,24 +94,9 @@ internal class SendAmountFieldChangeConverter(
val fiatDecimal = parseToBigDecimal(amountTextField.fiatAmount.decimals)
val cryptoDecimal = parseToBigDecimal(amountTextField.cryptoAmount.decimals)
return if (amountTextField.isFiatValue) {
fiatDecimal > currencyFiatAmount || fiatDecimal.isZero()
fiatDecimal > currencyFiatAmount
} else {
cryptoDecimal > currencyCryptoAmount || cryptoDecimal.isZero()
}
}
private fun String.checkMaxAmount(amountTextField: SendTextField.AmountField): Boolean {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
// If current currency is Token
if (cryptoCurrencyStatus.currency is CryptoCurrency.Token) return false
val currencyCryptoAmount = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val currencyFiatAmount = cryptoCurrencyStatus.value.fiatAmount ?: BigDecimal.ZERO
return if (amountTextField.isFiatValue) {
parseToBigDecimal(amountTextField.fiatAmount.decimals) == currencyFiatAmount
} else {
parseToBigDecimal(amountTextField.cryptoAmount.decimals) == currencyCryptoAmount
cryptoDecimal > currencyCryptoAmount
}
}
}

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.extensions.toBigDecimalOrDefault
import com.tangem.common.extensions.isZero
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
@ -36,12 +37,13 @@ internal class SendAmountFieldConverter(
val fiatDecimal = cryptoCurrencyStatus.value.fiatRate?.multiply(cryptoDecimal) ?: BigDecimal.ZERO
fiatDecimal.parseBigDecimal(FIAT_DECIMALS)
}
val isDoneActionEnabled = !cryptoDecimal.isZero()
return SendTextField.AmountField(
value = value,
fiatValue = fiatValue,
onValueChange = clickIntents::onAmountValueChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(onDone = { clickIntents.onNextClick() }),

View file

@ -1,5 +1,8 @@
package com.tangem.features.send.impl.presentation.state.fields
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
@ -17,7 +20,6 @@ internal class SendAmountFieldMaxAmountConverter(
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val amountState = state.amountState ?: return state
val amountTextField = amountState.amountTextField
val feeState = state.feeState ?: return state
val cryptoDecimals = amountTextField.cryptoAmount.decimals
val fiatDecimals = amountTextField.fiatAmount.decimals
@ -26,6 +28,7 @@ internal class SendAmountFieldMaxAmountConverter(
if (decimalCryptoValue.isNullOrZero()) return state
val isDoneActionEnabled = !decimalCryptoValue.isNullOrZero()
val cryptoValue = decimalCryptoValue?.parseBigDecimal(cryptoDecimals).orEmpty()
val fiatValue = decimalFiatValue?.parseBigDecimal(fiatDecimals).orEmpty()
return state.copy(
@ -37,11 +40,12 @@ internal class SendAmountFieldMaxAmountConverter(
isError = false,
cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue),
fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue),
keyboardOptions = KeyboardOptions(
imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None,
keyboardType = KeyboardType.Number,
),
),
),
feeState = feeState.copy(
isSubtract = true,
),
)
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.features.send.impl.presentation.state.recipient
import androidx.paging.*
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
@ -17,77 +16,66 @@ import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.utils.Provider
import com.tangem.utils.toFormattedCurrencyString
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.update
internal class SendRecipientListConverter(
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) {
fun convert(wallets: List<AvailableWallet?>, txHistory: PagingData<TxHistoryItem>, txHistoryCount: Int) {
val filteredWallets = wallets.filterNotNull()
.groupBy { item -> item.name }
.values.flatten()
.mapIndexed { index, item ->
item.copy(
name = "${item.name} ${index.inc()}",
)
}
val walletsItem = getWalletItems(filteredWallets, txHistoryCount)
fun convert(wallets: List<AvailableWallet?>, txHistory: List<TxHistoryItem>): SendUiState {
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
currentStateProvider().recipientList.update {
if (txHistoryCount == 0) {
PagingData.from(listOf(walletsItem))
} else {
txHistory.filter { item ->
val isTransfer = item.type == TxHistoryItem.TransactionType.Transfer
val isNotContract = item.interactionAddressType is TxHistoryItem.InteractionAddressType.User
val isSingleAddress = if (item.isOutgoing) {
item.destinationType is TxHistoryItem.DestinationType.Single
} else {
item.sourceType is TxHistoryItem.SourceType.Single
}
isTransfer && isSingleAddress && isNotContract
}.map<TxHistoryItem, SendRecipientListContent> { tx ->
SendRecipientListContent.Item(
id = tx.txHash,
title = tx.extractAddress(),
subtitle = stringReference(tx.getAmount(cryptoCurrency).trim()),
timestamp = tx.extractTimestamp(),
subtitleEndOffset = cryptoCurrency.symbol.length,
subtitleIconRes = tx.extractIconRes(),
)
}.insertWallets(walletsItem)
}
}
}
val state = currentStateProvider()
val recipientState = state.recipientState ?: return state
private fun PagingData<SendRecipientListContent>.insertWallets(
wallets: SendRecipientListContent.Wallets,
): PagingData<SendRecipientListContent> {
return insertSeparators(terminalSeparatorType = TerminalSeparatorType.SOURCE_COMPLETE) { before, after ->
return@insertSeparators when {
before == null && after is SendRecipientListContent.Item -> wallets
else -> null
}
}
}
private fun getWalletItems(wallets: List<AvailableWallet>, txHistoryCount: Int): SendRecipientListContent.Wallets {
return SendRecipientListContent.Wallets(
wallets.map {
SendRecipientListContent.Item(
id = it.address,
title = TextReference.Str(it.address),
subtitle = TextReference.Str(it.name),
)
}.toPersistentList(),
isWalletsOnly = txHistoryCount == 0,
return state.copy(
recipientState = recipientState.copy(
wallets = wallets.filterWallets(),
recent = txHistory.filterRecipients(cryptoCurrency),
),
)
}
private fun List<AvailableWallet?>.filterWallets() = this.filterNotNull()
.groupBy { item -> item.name }
.values.map {
it.mapIndexed { index, item ->
val name = if (it.size > 1) {
"${item.name} ${index.inc()}"
} else {
item.name
}
SendRecipientListContent(
id = item.address,
title = TextReference.Str(item.address),
subtitle = TextReference.Str(name),
)
}
}
.flatten()
.toPersistentList()
private fun List<TxHistoryItem>.filterRecipients(cryptoCurrency: CryptoCurrency) = this.filter { item ->
val isTransfer = item.type == TxHistoryItem.TransactionType.Transfer
val isNotContract = item.interactionAddressType is TxHistoryItem.InteractionAddressType.User
val isSingleAddress = if (item.isOutgoing) {
item.destinationType is TxHistoryItem.DestinationType.Single
} else {
item.sourceType is TxHistoryItem.SourceType.Single
}
isTransfer && isSingleAddress && isNotContract
}
.take(RECENT_LIST_SIZE)
.map { tx ->
SendRecipientListContent(
id = tx.txHash,
title = tx.extractAddress(),
subtitle = stringReference(tx.getAmount(cryptoCurrency).trim()),
timestamp = tx.extractTimestamp(),
subtitleEndOffset = cryptoCurrency.symbol.length,
subtitleIconRes = tx.extractIconRes(),
)
}.toPersistentList()
private fun TxHistoryItem.extractAddress(): TextReference = if (isOutgoing) {
when (val destination = destinationType) {
is TxHistoryItem.DestinationType.Multiple -> TextReference.Res(
@ -122,4 +110,8 @@ internal class SendRecipientListConverter(
val time = timestampInMillis.toTimeFormat()
return TextReference.Res(R.string.send_date_format, wrappedList(date, time))
}
companion object {
private const val RECENT_LIST_SIZE = 10
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
internal class SendRecipientStateConverter(
private val clickIntents: SendClickIntents,
@ -25,6 +26,8 @@ internal class SendRecipientStateConverter(
memoTextField = memoFieldConverter.convertOrNull(),
network = cryptoCurrencyStatusProvider().currency.network.name,
isPrimaryButtonEnabled = false,
wallets = persistentListOf(),
recent = persistentListOf(),
)
}
}

View file

@ -15,7 +15,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -24,25 +23,29 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.extensions.shareText
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendUiCurrentScreen
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
@Composable
internal fun SendNavigationButtons(uiState: SendUiState) {
internal fun SendNavigationButtons(uiState: SendUiState, currentState: State<SendUiCurrentScreen>) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = TangemTheme.dimens.spacing12),
) {
SendSecondaryNavigationButton(uiState)
SendSecondaryNavigationButton(
uiState = uiState,
currentState = currentState,
)
SendPrimaryNavigationButton(
uiState = uiState,
currentState = currentState,
modifier = Modifier
.weight(1f)
.padding(horizontal = TangemTheme.dimens.spacing16),
@ -51,12 +54,13 @@ internal fun SendNavigationButtons(uiState: SendUiState) {
}
@Composable
private fun SendSecondaryNavigationButton(uiState: SendUiState) {
val currentState = uiState.currentState.collectAsState()
private fun SendSecondaryNavigationButton(uiState: SendUiState, currentState: State<SendUiCurrentScreen>) {
val isEditingDisabled = uiState.isEditingDisabled
val isCorrectScreen = currentState.value == SendUiStateType.Amount || currentState.value == SendUiStateType.Fee
val isFromConfirmation = currentState.value.isFromConfirmation
val isCorrectScreen =
currentState.value.type == SendUiStateType.Amount || currentState.value.type == SendUiStateType.Fee
AnimatedVisibility(
visible = !isEditingDisabled && isCorrectScreen,
visible = !isEditingDisabled && isCorrectScreen && !isFromConfirmation,
enter = expandHorizontally(expandFrom = Alignment.End),
exit = shrinkHorizontally(shrinkTowards = Alignment.End),
) {
@ -77,8 +81,11 @@ private fun SendSecondaryNavigationButton(uiState: SendUiState) {
}
@Composable
private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier = Modifier) {
val currentState = uiState.currentState.collectAsStateWithLifecycle()
private fun SendPrimaryNavigationButton(
uiState: SendUiState,
currentState: State<SendUiCurrentScreen>,
modifier: Modifier = Modifier,
) {
val isSuccess = uiState.sendState.isSuccess
val isSending = uiState.sendState.isSending
val txUrl = uiState.sendState.txUrl
@ -100,7 +107,7 @@ private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier
modifier = modifier,
) { textId ->
when {
currentState.value == SendUiStateType.Send && !isSuccess -> {
currentState.value.type == SendUiStateType.Send && !isSuccess -> {
val hapticFeedback = rememberHapticFeedback(state = currentState, onAction = buttonClick)
PrimaryButtonIconEnd(
text = stringResource(textId),
@ -110,11 +117,11 @@ private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier
showProgress = isSending,
)
}
currentState.value == SendUiStateType.Send && isSuccess -> {
currentState.value.type == SendUiStateType.Send && isSuccess -> {
PrimaryButtonsDone(
textRes = textId,
txUrl = txUrl,
onExploreClick = { uiState.clickIntents.onExploreClick(txUrl) },
onExploreClick = uiState.clickIntents::onExploreClick,
onShareClick = uiState.clickIntents::onShareClick,
onDoneClick = buttonClick,
modifier = Modifier,
@ -177,15 +184,19 @@ private fun PrimaryButtonsDone(
private fun getButtonData(
uiState: SendUiState,
currentState: State<SendUiStateType>,
currentState: State<SendUiCurrentScreen>,
isSuccess: Boolean,
): Pair<Int, () -> Unit> {
return when (currentState.value) {
return when (currentState.value.type) {
SendUiStateType.None,
SendUiStateType.Amount,
SendUiStateType.Recipient,
SendUiStateType.Fee,
-> R.string.common_next to uiState.clickIntents::onNextClick
-> if (currentState.value.isFromConfirmation) {
R.string.common_continue to uiState.clickIntents::onNextClick
} else {
R.string.common_next to uiState.clickIntents::onNextClick
}
SendUiStateType.Send -> if (isSuccess) {
R.string.common_close
} else {
@ -194,8 +205,8 @@ private fun getButtonData(
}
}
private fun isButtonEnabled(currentState: State<SendUiStateType>, uiState: SendUiState): Boolean {
return when (currentState.value) {
private fun isButtonEnabled(currentState: State<SendUiCurrentScreen>, uiState: SendUiState): Boolean {
return when (currentState.value.type) {
SendUiStateType.Amount -> uiState.amountState?.isPrimaryButtonEnabled ?: false
SendUiStateType.Recipient -> uiState.recipientState?.isPrimaryButtonEnabled ?: false
SendUiStateType.Fee -> uiState.feeState?.isPrimaryButtonEnabled ?: false

View file

@ -15,21 +15,21 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendUiCurrentScreen
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.ui.amount.SendAmountContent
import com.tangem.features.send.impl.presentation.ui.fee.SendSpeedAndFeeContent
import com.tangem.features.send.impl.presentation.ui.recipient.SendRecipientContent
import com.tangem.features.send.impl.presentation.ui.send.SendContent
import kotlinx.coroutines.flow.StateFlow
@Composable
internal fun SendScreen(uiState: SendUiState) {
val currentState = uiState.currentState.collectAsStateWithLifecycle()
val isSuccess = uiState.sendState.isSuccess
internal fun SendScreen(uiState: SendUiState, currentStateFlow: StateFlow<SendUiCurrentScreen>) {
val currentState = currentStateFlow.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
BackHandler { uiState.clickIntents.onBackClick() }
Column(
@ -40,14 +40,14 @@ internal fun SendScreen(uiState: SendUiState) {
.background(color = TangemTheme.colors.background.tertiary),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val titleRes = when (currentState.value) {
val titleRes = when (currentState.value.type) {
SendUiStateType.Amount -> R.string.send_amount_label
SendUiStateType.Recipient -> R.string.send_recipient_label
SendUiStateType.Fee -> R.string.common_fee_selector_title
SendUiStateType.Send -> if (!isSuccess) R.string.send_confirm_label else null
SendUiStateType.Send -> if (!uiState.sendState.isSuccess) R.string.send_confirm_label else null
else -> null
}
val iconRes = if (currentState.value == SendUiStateType.Recipient) {
val iconRes = if (currentState.value.type == SendUiStateType.Recipient) {
R.drawable.ic_qrcode_scan_24
} else {
null
@ -67,7 +67,7 @@ internal fun SendScreen(uiState: SendUiState) {
modifier = Modifier
.weight(1f),
)
SendNavigationButtons(uiState)
SendNavigationButtons(uiState, currentState)
}
SendEventEffect(
@ -79,16 +79,15 @@ internal fun SendScreen(uiState: SendUiState) {
@Composable
private fun SendScreenContent(
uiState: SendUiState,
currentState: State<SendUiStateType>,
currentState: State<SendUiCurrentScreen>,
modifier: Modifier = Modifier,
) {
val recipientList = uiState.recipientList.collectAsLazyPagingItems()
AnimatedContent(
targetState = currentState.value,
label = "Send Scree Navigation",
modifier = modifier,
) { state ->
when (state) {
when (state.type) {
SendUiStateType.Amount -> SendAmountContent(
amountState = uiState.amountState,
isBalanceHiding = uiState.isBalanceHidden,
@ -97,7 +96,6 @@ private fun SendScreenContent(
SendUiStateType.Recipient -> SendRecipientContent(
uiState = uiState.recipientState,
clickIntents = uiState.clickIntents,
recipientList = recipientList,
)
SendUiStateType.Fee -> SendSpeedAndFeeContent(
state = uiState.feeState,

View file

@ -15,7 +15,6 @@ import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fee.SendFeeNotification
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import kotlinx.collections.immutable.ImmutableList
@ -41,8 +40,6 @@ internal fun SendSpeedAndFeeContent(state: SendStates.FeeState?, clickIntents: S
topNotifications(notifications)
customFee(feeSendState)
middleNotifications(notifications)
subtractButton(state, clickIntents)
bottomNotifications(notifications)
}
}
@ -77,21 +74,7 @@ private fun LazyListScope.middleNotifications(
modifier: Modifier = Modifier,
) {
notifications(
configs = configs.filter {
it is SendFeeNotification.Warning.TooLow ||
it is SendFeeNotification.Warning.TooHigh
}.toImmutableList(),
modifier = modifier,
)
}
private fun LazyListScope.bottomNotifications(
configs: ImmutableList<SendFeeNotification>,
modifier: Modifier = Modifier,
) {
notifications(
configs = configs.filterIsInstance<SendFeeNotification.Warning.NetworkCoverage>().toImmutableList(),
isLast = true,
configs = configs.filterIsInstance<SendFeeNotification.Warning.TooHigh>().toImmutableList(),
modifier = modifier,
)
}
@ -159,36 +142,4 @@ internal fun LazyListScope.customFee(feeSendState: FeeSelectorState, modifier: M
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.subtractButton(
state: SendStates.FeeState,
clickIntents: SendClickIntents,
modifier: Modifier = Modifier,
) {
val receivedAmount = state.receivedAmount
val isSubtract = state.isSubtract
val isSubtractAvailable = state.isSubtractAvailable
val feeSendState = state.feeSelectorState
if (isSubtractAvailable) {
item {
val feeStateContent = feeSendState as? FeeSelectorState.Content
val isCustomAvailable = feeStateContent?.customValues.isNullOrEmpty().not()
val isCustomSelected = feeStateContent?.selectedFee == FeeType.Custom
val topPadding = if (isCustomSelected && isCustomAvailable) {
TangemTheme.dimens.spacing12
} else {
TangemTheme.dimens.spacing20
}
SendSpeedSubtract(
receivingAmount = receivedAmount,
isSubtract = isSubtract,
onSelectClick = clickIntents::onSubtractSelect,
modifier = modifier
.padding(top = topPadding)
.animateItemPlacement(),
)
}
}
}

View file

@ -10,18 +10,23 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.Fee
@ -43,7 +48,6 @@ import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fee.SendFeeNotification
import com.tangem.features.send.impl.presentation.ui.common.FooterContainer
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import java.math.BigDecimal
@ -60,10 +64,7 @@ internal fun SendSpeedSelector(
clickIntents: SendClickIntents,
modifier: Modifier = Modifier,
) {
FooterContainer(
footer = stringResource(R.string.common_fee_selector_footer),
modifier = modifier,
) {
Column(modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
@ -116,10 +117,7 @@ internal fun SendSpeedSelector(
visible = fees.normal is Fee.Ethereum,
label = "Custom fee appearance animation",
) {
val showWarning = state.notifications.any {
it is SendFeeNotification.Warning.TooHigh ||
it is SendFeeNotification.Warning.TooLow
}
val showWarning = state.notifications.any { it is SendFeeNotification.Warning.TooHigh }
SendSpeedSelectorItem(
titleRes = R.string.common_fee_selector_option_custom,
iconRes = R.drawable.ic_edit_24,
@ -147,9 +145,43 @@ internal fun SendSpeedSelector(
}
}
}
FooterText(clickIntents::onReadMoreClick)
}
}
@Composable
private fun FooterText(onReadMoreClick: () -> Unit) {
val linkText = stringResource(R.string.common_read_more)
val fullString = stringResource(R.string.common_fee_selector_footer, linkText)
val linkTextPosition = fullString.length - linkText.length
val defaultStyle = TangemTheme.colors.text.tertiary
val linkStyle = TangemTheme.colors.text.accent
val annotatedString = remember(defaultStyle, linkStyle) {
buildAnnotatedString {
withStyle(SpanStyle(defaultStyle)) {
append(fullString.substring(0, linkTextPosition))
}
withStyle(SpanStyle(linkStyle)) {
append(fullString.substring(linkTextPosition, fullString.length))
}
}
}
val click = { i: Int ->
val readMoreStyle = requireNotNull(annotatedString.spanStyles.getOrNull(1))
if (i in readMoreStyle.start..readMoreStyle.end) {
onReadMoreClick()
}
}
ClickableText(
text = annotatedString,
style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Start),
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
onClick = click,
)
}
// todo remove after refactoring [REDACTED_JIRA]
private fun getCryptoReference(amount: Amount, isFeeApproximate: Boolean) = combinedReference(
if (isFeeApproximate) stringReference("$CAN_BE_LOWER_SIGN ") else TextReference.EMPTY,

View file

@ -1,61 +0,0 @@
package com.tangem.features.send.impl.presentation.ui.fee
import androidx.compose.foundation.background
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.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.TangemSwitch
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.ui.common.FooterContainer
@Composable
internal fun SendSpeedSubtract(
receivingAmount: String,
isSubtract: Boolean,
onSelectClick: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val footerText = if (isSubtract) {
stringResource(R.string.send_amount_substract_footer, receivingAmount)
} else {
null
}
FooterContainer(
footer = footerText,
modifier = modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.padding(
vertical = TangemTheme.dimens.spacing16,
horizontal = TangemTheme.dimens.spacing20,
),
) {
Text(
text = stringResource(R.string.send_amount_substract),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(end = TangemTheme.dimens.spacing12),
)
TangemSwitch(
checked = isSubtract,
onCheckedChange = onSelectClick,
)
}
}
}

View file

@ -3,9 +3,7 @@ package com.tangem.features.send.impl.presentation.ui.recipient
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
@ -18,9 +16,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import androidx.constraintlayout.compose.Visibility
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.icons.identicon.IdentIcon
@ -39,7 +34,6 @@ import com.tangem.features.send.impl.R
* @param subtitleEndOffset offset for subtitle ellipsis
* @param subtitleIconRes icon
*/
@Suppress("DestructuringDeclarationWithTooManyEntries", "LongMethod")
@Composable
fun ListItemWithIcon(
title: String,
@ -51,80 +45,60 @@ fun ListItemWithIcon(
@DrawableRes subtitleIconRes: Int? = null,
) {
val hapticFeedback = rememberHapticFeedback(state = title, onAction = onClick)
ConstraintLayout(
Row(
modifier = modifier
.fillMaxWidth()
.clickable { hapticFeedback() }
.padding(horizontal = TangemTheme.dimens.spacing12),
) {
val (iconRef, titleRef, subtitleRef, subtitleIconRef) = createRefs()
val spacing2 = TangemTheme.dimens.spacing2
val spacing8 = TangemTheme.dimens.spacing8
val spacing10 = TangemTheme.dimens.spacing10
val spacing12 = TangemTheme.dimens.spacing12
IdentIcon(
address = title,
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing8)
.size(TangemTheme.dimens.size40)
.clip(RoundedCornerShape(TangemTheme.dimens.radius20))
.constrainAs(iconRef) {
start.linkTo(parent.start)
top.linkTo(parent.top, margin = spacing8)
bottom.linkTo(parent.bottom, margin = spacing8)
},
.clip(RoundedCornerShape(TangemTheme.dimens.radius20)),
)
EllipsisText(
text = title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Justify,
ellipsis = TextEllipsis.Middle,
Column(
modifier = Modifier
.constrainAs(titleRef) {
start.linkTo(iconRef.end, margin = spacing12)
end.linkTo(parent.end)
top.linkTo(parent.top, margin = spacing10)
width = Dimension.fillToConstraints
},
)
Icon(
painter = painterResource(id = subtitleIconRes ?: R.drawable.ic_arrow_down_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.size(TangemTheme.dimens.size16)
.background(TangemTheme.colors.icon.informative.copy(alpha = 0.1f), CircleShape)
.constrainAs(subtitleIconRef) {
start.linkTo(iconRef.end, margin = spacing12)
top.linkTo(titleRef.bottom)
bottom.linkTo(parent.bottom, margin = spacing10)
visibility = if (subtitleIconRes == null) Visibility.Gone else Visibility.Visible
},
)
val (text, offset) = remember(subtitle, info) {
if (info != null) {
val suffix = ", $info"
subtitle + suffix to suffix.length + subtitleEndOffset
} else {
subtitle to 0
.padding(vertical = TangemTheme.dimens.spacing10)
.padding(start = TangemTheme.dimens.spacing12),
) {
EllipsisText(
text = title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Justify,
ellipsis = TextEllipsis.Middle,
modifier = Modifier,
)
Row {
if (subtitleIconRes != null) {
Icon(
painter = painterResource(id = subtitleIconRes),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.size(TangemTheme.dimens.size16)
.background(TangemTheme.colors.background.tertiary, CircleShape),
)
}
val (text, offset) = remember(subtitle, info) {
if (info != null) {
val suffix = ", $info"
subtitle + suffix to suffix.length + subtitleEndOffset
} else {
subtitle to 0
}
}
EllipsisText(
text = text,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = TextEllipsis.OffsetEnd(offsetEnd = offset),
modifier = Modifier.padding(start = TangemTheme.dimens.spacing2),
)
}
}
EllipsisText(
text = text,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = TextEllipsis.OffsetEnd(offsetEnd = offset),
modifier = Modifier
.constrainAs(subtitleRef) {
start.linkTo(subtitleIconRef.end, margin = spacing2, goneMargin = spacing12)
end.linkTo(parent.end)
top.linkTo(titleRef.bottom)
bottom.linkTo(parent.bottom, margin = spacing10)
width = Dimension.fillToConstraints
},
)
}
}

View file

@ -1,8 +1,9 @@
package com.tangem.features.send.impl.presentation.ui.recipient
import androidx.annotation.StringRes
import androidx.compose.animation.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -17,9 +18,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import com.tangem.core.ui.components.inputrow.InputRowRecipient
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
@ -29,18 +27,17 @@ import com.tangem.features.send.impl.presentation.domain.SendRecipientListConten
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.ui.common.FooterContainer
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import kotlinx.collections.immutable.ImmutableList
private const val ADDRESS_FIELD_KEY = "ADDRESS_FIELD_KEY"
private const val MEMO_FIELD_KEY = "MEMO_FIELD_KEY"
private const val MY_WALLETS_HEADER_KEY = "MY_WALLETS_HEADER_KEY"
@Composable
internal fun SendRecipientContent(
uiState: SendStates.RecipientState?,
clickIntents: SendClickIntents,
recipientList: LazyPagingItems<SendRecipientListContent>,
) {
internal fun SendRecipientContent(uiState: SendStates.RecipientState?, clickIntents: SendClickIntents) {
if (uiState == null) return
val recipients = uiState.recent
val wallets = uiState.wallets
val memoField = uiState.memoTextField
val address = uiState.addressTextField
val isValidating by remember(uiState.isValidating) { derivedStateOf { uiState.isValidating } }
val isError by remember(address.isError) { derivedStateOf { address.isError } }
@ -72,7 +69,7 @@ internal fun SendRecipientContent(
)
}
}
uiState.memoTextField?.let { memoField ->
if (memoField != null) {
item(key = MEMO_FIELD_KEY) {
val placeholder = if (memoField.isEnabled) memoField.placeholder else memoField.disabledText
TextFieldWithPaste(
@ -89,144 +86,115 @@ internal fun SendRecipientContent(
)
}
}
recipientListItem(
recipientList = recipientList,
clickIntents = clickIntents,
listHeaderItem(
titleRes = R.string.send_recipient_wallets_title,
isVisible = wallets.isNotEmpty() && wallets.first().isVisible,
isFirst = true,
)
listItem(wallets, clickIntents, isLast = recipients.isEmpty())
listHeaderItem(
titleRes = R.string.send_recent_transactions,
isVisible = recipients.isNotEmpty() && recipients.first().isVisible,
isFirst = wallets.isEmpty(),
)
listItem(recipients, clickIntents, isLast = true)
}
}
@Suppress("LongMethod")
@OptIn(ExperimentalFoundationApi::class)
private fun LazyListScope.recipientListItem(
recipientList: LazyPagingItems<SendRecipientListContent>,
private fun LazyListScope.listHeaderItem(@StringRes titleRes: Int, isVisible: Boolean, isFirst: Boolean) {
item(
key = titleRes,
) {
AnimatedVisibility(
visible = isVisible,
label = "Header Appearance Animation",
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
modifier = Modifier
.animateItemPlacement()
.animateContentSize(),
) {
val (topPadding, paddingFromTop) = if (isFirst) {
TangemTheme.dimens.spacing20 to TangemTheme.dimens.spacing12
} else {
TangemTheme.dimens.spacing0 to TangemTheme.dimens.spacing8
}
val topRadius = if (isFirst) {
TangemTheme.dimens.radius12
} else {
TangemTheme.dimens.radius0
}
Text(
text = stringResource(titleRes),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.fillMaxWidth()
.padding(top = topPadding)
.clip(
RoundedCornerShape(
topEnd = topRadius,
topStart = topRadius,
),
)
.background(TangemTheme.colors.background.action)
.padding(
top = paddingFromTop,
bottom = TangemTheme.dimens.spacing8,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
private fun LazyListScope.listItem(
list: ImmutableList<SendRecipientListContent>,
clickIntents: SendClickIntents,
isLast: Boolean,
) {
items(
count = recipientList.itemCount,
key = recipientList.itemKey {
when (it) {
is SendRecipientListContent.Wallets -> MY_WALLETS_HEADER_KEY
is SendRecipientListContent.Item -> it.id
}
},
contentType = recipientList.itemContentType { it::class.java },
count = list.size,
key = { list[it].id },
contentType = { list[it]::class.java },
) { index ->
recipientList[index]?.let { item ->
when (item) {
is SendRecipientListContent.Wallets -> {
RecipientWalletListItem(
item = item,
clickIntents = clickIntents,
modifier = Modifier
.animateItemPlacement()
.padding(top = TangemTheme.dimens.spacing20)
.then(
if (index == 0) {
val bottomRadius = if (item.isWalletsOnly) {
TangemTheme.dimens.radius12
} else {
TangemTheme.dimens.radius0
}
Modifier.clip(
RoundedCornerShape(
topEnd = TangemTheme.dimens.radius12,
topStart = TangemTheme.dimens.radius12,
bottomStart = bottomRadius,
bottomEnd = bottomRadius,
),
)
} else {
Modifier
},
),
)
}
is SendRecipientListContent.Item -> {
val title = item.title.resolveReference()
ListItemWithIcon(
title = item.title.resolveReference(),
subtitle = item.subtitle.resolveReference(),
info = item.timestamp?.resolveReference(),
subtitleEndOffset = item.subtitleEndOffset,
subtitleIconRes = item.subtitleIconRes,
modifier = Modifier
.then(
if (index == recipientList.itemCount - 1) {
Modifier
.padding(bottom = TangemTheme.dimens.spacing20)
.clip(
RoundedCornerShape(
bottomEnd = TangemTheme.dimens.radius12,
bottomStart = TangemTheme.dimens.radius12,
),
)
} else {
Modifier
},
)
.background(TangemTheme.colors.background.action),
onClick = {
clickIntents.onRecipientAddressValueChange(title, EnterAddressSource.RecentAddress)
val item = list[index]
val title = item.title.resolveReference()
AnimatedVisibility(
visible = item.isVisible,
label = "Header Appearance Animation",
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
modifier = Modifier
.animateItemPlacement()
.animateContentSize(),
) {
ListItemWithIcon(
title = title,
subtitle = item.subtitle.resolveReference(),
info = item.timestamp?.resolveReference(),
subtitleEndOffset = item.subtitleEndOffset,
subtitleIconRes = item.subtitleIconRes,
onClick = { clickIntents.onRecipientAddressValueChange(title, EnterAddressSource.RecentAddress) },
modifier = Modifier
.then(
if (isLast && index == list.lastIndex) {
Modifier
.padding(bottom = TangemTheme.dimens.spacing12)
.clip(
shape = RoundedCornerShape(
bottomStart = TangemTheme.dimens.radius16,
bottomEnd = TangemTheme.dimens.radius16,
),
)
} else {
Modifier
},
)
}
}
}
}
}
@Composable
private fun RecipientWalletListItem(
item: SendRecipientListContent.Wallets,
clickIntents: SendClickIntents,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.background(TangemTheme.colors.background.action)
.padding(top = TangemTheme.dimens.spacing12),
) {
if (item.list.isNotEmpty()) {
Text(
text = stringResource(R.string.send_recipient_wallets_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.fillMaxWidth()
.padding(
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing8,
),
)
}
item.list.forEachIndexed { _, wallet ->
val title = wallet.title.resolveReference()
ListItemWithIcon(
title = wallet.title.resolveReference(),
subtitle = wallet.subtitle.resolveReference(),
onClick = { clickIntents.onRecipientAddressValueChange(title, EnterAddressSource.RecentAddress) },
)
}
if (!item.isWalletsOnly) {
val topPadding = if (item.list.isNotEmpty()) {
TangemTheme.dimens.spacing8
} else {
TangemTheme.dimens.spacing0
}
Text(
text = stringResource(R.string.send_recent_transactions),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.fillMaxWidth()
.padding(
top = topPadding,
bottom = TangemTheme.dimens.spacing8,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
.background(TangemTheme.colors.background.action),
)
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.send.impl.presentation.viewmodel
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.state.SendNotification
import com.tangem.features.send.impl.presentation.state.fee.FeeType
@Suppress("TooManyFunctions")
@ -43,7 +44,9 @@ internal interface SendClickIntents {
fun onCustomFeeValueChange(index: Int, value: String)
fun onSubtractSelect(value: Boolean)
fun onSubtractSelect()
fun onReadMoreClick()
// endregion
// region Send
@ -55,12 +58,14 @@ internal interface SendClickIntents {
fun showFee()
fun onExploreClick(txUrl: String)
fun showSend()
fun onExploreClick()
fun onShareClick()
fun onAmountReduceClick(reducedAmount: String)
fun onAmountReduceClick(reducedAmount: String, clazz: Class<out SendNotification>)
fun onAmountReduceIgnoreClick()
fun onNotificationCancel(clazz: Class<out SendNotification>)
// endregion
}

View file

@ -4,7 +4,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.*
import androidx.paging.PagingData
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.blockchain.common.TransactionData
@ -27,8 +26,7 @@ import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
@ -44,18 +42,20 @@ import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.state.*
import com.tangem.features.send.impl.presentation.state.amount.AmountStateFactory
import com.tangem.features.send.impl.presentation.state.fee.FeeNotificationFactory
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeStateFactory
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fee.checkFeeCoverage
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
import kotlin.properties.Delegates
@ -70,8 +70,7 @@ internal class SendViewModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase,
private val txHistoryItemsUseCase: GetTxHistoryItemsUseCase,
private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase,
private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
private val createTransactionUseCase: CreateTransactionUseCase,
@ -104,7 +103,8 @@ internal class SendViewModel @Inject constructor(
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
private var innerRouter: InnerSendRouter by Delegates.notNull()
private var stateRouter: StateRouter by Delegates.notNull()
var stateRouter: StateRouter by Delegates.notNull()
private set
private val stateFactory = SendStateFactory(
clickIntents = this,
@ -124,7 +124,6 @@ internal class SendViewModel @Inject constructor(
private val feeStateFactory = FeeStateFactory(
clickIntents = this,
currentStateProvider = Provider { uiState },
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
isFeeApproximateUseCase = isFeeApproximateUseCase,
@ -141,6 +140,7 @@ internal class SendViewModel @Inject constructor(
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
currentStateProvider = Provider { uiState },
userWalletProvider = Provider { userWallet },
stateRouterProvider = Provider { stateRouter },
clickIntents = this,
getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase,
)
@ -150,6 +150,7 @@ internal class SendViewModel @Inject constructor(
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
currentStateProvider = Provider { uiState },
userWalletProvider = Provider { userWallet },
stateRouterProvider = Provider { stateRouter },
currencyChecksRepository = currencyChecksRepository,
clickIntents = this,
)
@ -170,6 +171,7 @@ internal class SendViewModel @Inject constructor(
private var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
private var balanceJobHolder = JobHolder()
private var balanceHidingJobHolder = JobHolder()
private var recipientsJobHolder = JobHolder()
private var feeJobHolder = JobHolder()
private var addressValidationJobHolder = JobHolder()
@ -179,26 +181,35 @@ internal class SendViewModel @Inject constructor(
private var sendIdleTimer = 0L
init {
subscribeOnCurrencyStatusUpdates()
subscribeOnBalanceHidden()
}
override fun onCreate(owner: LifecycleOwner) {
subscribeOnCurrencyStatusUpdates(owner)
onStateActive()
subscribeOnBalanceHidden(owner)
analyticsEventHandler.send(SendAnalyticEvents.SendOpened)
}
override fun onCleared() {
super.onCleared()
balanceHidingJobHolder.cancel()
balanceJobHolder.cancel()
stateRouter.clear()
}
fun setRouter(router: InnerSendRouter, stateRouter: StateRouter) {
innerRouter = router
this.stateRouter = stateRouter
uiState = uiState.copy(currentState = stateRouter.currentState)
}
private fun subscribeOnCurrencyStatusUpdates(owner: LifecycleOwner) {
private fun subscribeOnCurrencyStatusUpdates() {
viewModelScope.launch(dispatchers.main) {
getUserWalletUseCase(userWalletId).fold(
ifRight = { wallet ->
userWallet = wallet
checkIfSubtractAvailable()
getCurrenciesStatusUpdates(owner, wallet)
getCurrenciesStatusUpdates(wallet)
},
ifLeft = {
uiState = eventStateFactory.getGenericErrorState(
@ -210,23 +221,22 @@ internal class SendViewModel @Inject constructor(
}
}
private fun subscribeOnBalanceHidden(owner: LifecycleOwner) {
private fun subscribeOnBalanceHidden() {
getBalanceHidingSettingsUseCase()
.flowWithLifecycle(owner.lifecycle)
.conflate()
.distinctUntilChanged()
.onEach {
uiState = stateFactory.getOnHideBalanceState(isBalanceHidden = it.isBalanceHidden)
}
.launchIn(viewModelScope)
.saveIn(balanceHidingJobHolder)
}
private fun getCurrenciesStatusUpdates(owner: LifecycleOwner, wallet: UserWallet) {
private fun getCurrenciesStatusUpdates(wallet: UserWallet) {
val isSingleWallet = wallet.scanResponse.walletData?.token != null && !wallet.isMultiCurrency
if (cryptoCurrency is CryptoCurrency.Coin) {
getCurrencyStatusUpdates(isSingleWallet = isSingleWallet)
.flowWithLifecycle(owner.lifecycle)
.onEach { currencyStatus ->
currencyStatus.onRight {
onDataLoaded(
@ -249,7 +259,7 @@ internal class SendViewModel @Inject constructor(
coinCurrencyStatus = coinStatus.getOrElse { error("Coin status is unreachable") },
)
}
}.flowWithLifecycle(owner.lifecycle)
}
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
.saveIn(balanceJobHolder)
@ -300,12 +310,10 @@ internal class SendViewModel @Inject constructor(
combine(
flow = getUserWallets().conflate(),
flow2 = getTxHistory().conflate(),
flow3 = getTxHistoryCount().conflate(),
) { wallets, txHistory, txHistoryCount ->
stateFactory.onLoadedRecipientList(
) { wallets, txHistory ->
uiState = stateFactory.onLoadedRecipientList(
wallets = wallets,
txHistory = txHistory,
txHistoryCount = txHistoryCount,
)
}
.flowOn(dispatchers.io)
@ -322,7 +330,7 @@ internal class SendViewModel @Inject constructor(
.filterNot { it.walletId == userWalletId || it.isLocked }
.map { wallet ->
async(dispatchers.io) {
getCryptoCurrenciesUseCase(wallet.walletId)
getCryptoCurrenciesUseCase.getSync(wallet.walletId)
.fold(
ifRight = { currencyItem ->
val walletCurrency = currencyItem.firstOrNull {
@ -347,35 +355,21 @@ internal class SendViewModel @Inject constructor(
}
}
private fun getTxHistory(): Flow<PagingData<TxHistoryItem>> {
return flow {
txHistoryItemsUseCase(
userWalletId = userWalletId,
currency = cryptoCurrency,
).fold(
ifRight = { emitAll(it.distinctUntilChanged()) },
ifLeft = {},
)
}
}
private fun getTxHistoryCount(): Flow<Int> {
return flow {
txHistoryItemsCountUseCase(
userWalletId = userWalletId,
currency = cryptoCurrency,
).fold(
ifRight = { emit(it) },
ifLeft = { emit(0) },
)
}
private fun getTxHistory(): Flow<List<TxHistoryItem>> {
return getFixedTxHistoryItemsUseCase(
userWalletId = userWalletId,
currency = cryptoCurrency,
).fold(
ifRight = { it.distinctUntilChanged() },
ifLeft = { emptyFlow() },
)
}
private fun onStateActive() {
uiState.currentState
stateRouter.currentState
.onEach {
when (it) {
SendUiStateType.Fee -> loadFee()
when (it.type) {
SendUiStateType.Fee -> if (!it.isFromConfirmation) loadFee()
SendUiStateType.Send -> sendIdleTimer = System.currentTimeMillis()
else -> Unit
}
@ -407,6 +401,23 @@ internal class SendViewModel @Inject constructor(
override fun popBackStack() = stateRouter.popBackStack()
override fun onBackClick() = stateRouter.onBackClick(uiState.sendState.isSuccess)
override fun onNextClick() {
val currentState = stateRouter.currentState.value
val isCurrentFee = currentState.type == SendUiStateType.Fee
if (isCurrentFee) {
uiState = eventStateFactory.getFeeTooLowAlert(
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
)
val isFeeCoverage = checkFeeCoverage(uiState, cryptoCurrencyStatus)
if (isAmountSubtractAvailable && isFeeCoverage) {
uiState = eventStateFactory.getFeeCoverageAlert(
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
)
return
} else {
uiState = stateFactory.onSubtractSelect(false)
}
}
val prevScreen = stateRouter.onNextClick()
sendOnNextScreenAnalyticSender.send(prevScreen, uiState)
}
@ -482,11 +493,13 @@ internal class SendViewModel @Inject constructor(
}
private suspend fun validateAddress(value: String): Boolean {
return validateWalletAddressUseCase(
val isValidAddress = validateWalletAddressUseCase(
userWalletId = userWalletId,
network = cryptoCurrency.network,
address = value,
).getOrElse { false }
onEnteredValidAddress(isValidAddress)
return isValidAddress
}
private suspend fun checkIfXrpAddressValue(value: String): Boolean {
@ -498,6 +511,16 @@ internal class SendViewModel @Inject constructor(
true
} ?: false
}
private fun onEnteredValidAddress(isValidAddress: Boolean) {
val recipientState = uiState.recipientState ?: return
uiState = uiState.copy(
recipientState = recipientState.copy(
recent = recipientState.recent.map { it.copy(isVisible = !isValidAddress) }.toPersistentList(),
wallets = recipientState.wallets.map { it.copy(isVisible = !isValidAddress) }.toPersistentList(),
),
)
}
// endregion
// region fee
@ -516,21 +539,27 @@ internal class SendViewModel @Inject constructor(
updateFeeNotifications()
}
override fun onSubtractSelect(value: Boolean) {
uiState = feeStateFactory.onSubtractSelect(value)
updateFeeNotifications()
override fun onSubtractSelect() {
uiState = stateFactory.onSubtractSelect(true)
stateRouter.showSend()
}
override fun onReadMoreClick() {
val locale = if (Locale.getDefault().language == RU_LOCALE) RU_LOCALE else EN_LOCALE
val url = buildString {
append(FEE_READ_MORE_URL_FIRST_PART)
append(locale)
append(FEE_READ_MORE_URL_SECOND_PART)
}
innerRouter.openUrl(url)
}
private fun loadFee() {
viewModelScope.launch(dispatchers.main) {
uiState = feeStateFactory.onFeeOnLoadingState()
uiState = callFeeUseCase()?.fold(
ifRight = { fees ->
feeStateFactory.onFeeOnLoadedState(fees, isAmountSubtractAvailable)
},
ifLeft = {
feeStateFactory.onFeeOnErrorState()
},
ifRight = feeStateFactory::onFeeOnLoadedState,
ifLeft = { feeStateFactory.onFeeOnErrorState() },
) ?: feeStateFactory.onFeeOnErrorState()
updateFeeNotifications()
}.saveIn(feeJobHolder)
@ -573,48 +602,55 @@ internal class SendViewModel @Inject constructor(
}
override fun showAmount() {
stateRouter.showAmount()
uiState = stateFactory.onSubtractSelect(false)
stateRouter.showAmount(isFromConfirmation = true)
analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Amount))
}
override fun showRecipient() {
stateRouter.showRecipient()
uiState = stateFactory.onSubtractSelect(false)
stateRouter.showRecipient(isFromConfirmation = true)
analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Address))
}
override fun showFee() {
stateRouter.showFee()
uiState = stateFactory.onSubtractSelect(false)
stateRouter.showFee(isFromConfirmation = true)
analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Fee))
}
override fun onExploreClick(txUrl: String) {
override fun showSend() {
stateRouter.showSend()
}
override fun onExploreClick() {
analyticsEventHandler.send(SendAnalyticEvents.ExploreButtonClicked)
innerRouter.openUrl(txUrl)
innerRouter.openUrl(uiState.sendState.txUrl)
}
override fun onShareClick() {
analyticsEventHandler.send(SendAnalyticEvents.ShareButtonClicked)
}
override fun onAmountReduceClick(reducedAmount: String) {
override fun onAmountReduceClick(reducedAmount: String, clazz: Class<out SendNotification>) {
uiState = amountStateFactory.getOnAmountValueChange(reducedAmount)
uiState = sendNotificationFactory.dismissHighFeeWarningState()
uiState = sendNotificationFactory.dismissNotificationState(clazz)
loadFee()
}
override fun onAmountReduceIgnoreClick() {
uiState = sendNotificationFactory.dismissHighFeeWarningState()
override fun onNotificationCancel(clazz: Class<out SendNotification>) {
uiState = sendNotificationFactory.dismissNotificationState(clazz)
}
private fun verifyAndSendTransaction() {
val recipient = uiState.recipientState?.addressTextField?.value ?: return
val feeState = uiState.feeState ?: return
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val fee = feeState.fee ?: return
val memo = uiState.recipientState?.memoTextField?.value
val fee = feeStateFactory.feeConverter.convert(feeSelectorState)
val amountValue = uiState.amountState?.amountTextField?.cryptoAmount?.value ?: return
val amountToSend = if (feeState.isSubtract && isAmountSubtractAvailable) {
feeState.receivedAmountValue
val amountToSend = if (uiState.sendState.isSubtract && isAmountSubtractAvailable) {
val feeValue = fee.amount.value ?: return
amountValue.minus(feeValue)
} else {
amountValue
}
@ -677,11 +713,10 @@ internal class SendViewModel @Inject constructor(
}
private fun onCheckFeeUpdate() {
val isSending = uiState.sendState.isSending
val isSuccess = uiState.sendState.isSuccess
val noErrorNotifications = uiState.sendState.notifications.none { it is SendNotification.Error }
if (!isSending && !isSuccess && noErrorNotifications) {
if (!isSuccess && noErrorNotifications) {
viewModelScope.launch(dispatchers.main) {
val feeUpdatedState = callFeeUseCase()?.fold(
ifRight = {
@ -720,5 +755,10 @@ internal class SendViewModel @Inject constructor(
companion object {
private const val CHECK_FEE_UPDATE_DELAY = 60_000L
private const val BALANCE_UPDATE_DELAY = 10_000L
private const val RU_LOCALE = "ru"
private const val EN_LOCALE = "en"
private const val FEE_READ_MORE_URL_FIRST_PART = "https://tangem.com/"
private const val FEE_READ_MORE_URL_SECOND_PART = "/blog/post/what-is-a-transaction-fee-and-why-do-we-need-it/"
}
}

View file

@ -7,6 +7,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.swap.api"
}
dependencies {
/** DI */
implementation(deps.hilt.android)

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.swap.api" />

View file

@ -6,6 +6,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.swap.data"
}
dependencies {
/** AndroidX */

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.swap.data" />

View file

@ -7,6 +7,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.swap.presentation"
}
dependencies {
/** Core modules */
implementation(projects.core.analytics)

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.swap.presentation" />

View file

@ -27,8 +27,8 @@ sealed class ProviderState {
val subtitle: TextReference,
val selectionType: SelectionType,
val additionalBadge: AdditionalBadge,
val percentLowerThenBest: PercentDifference = PercentDifference.Empty,
val namePrefix: PrefixType,
val percentLowerThenBest: PercentLowerThanBest = PercentLowerThanBest.Empty,
override val onProviderClick: (String) -> Unit,
) : ProviderState()
@ -61,9 +61,9 @@ sealed class ProviderState {
}
@Immutable
sealed class PercentLowerThanBest {
data class Value(val value: Float) : PercentLowerThanBest()
object Empty : PercentLowerThanBest()
sealed class PercentDifference {
data class Value(val value: Float) : PercentDifference()
object Empty : PercentDifference()
}
object ProviderPercentDiffComparator : Comparator<ProviderState> {
@ -77,14 +77,14 @@ object ProviderPercentDiffComparator : Comparator<ProviderState> {
if (o1 is ProviderState.Content && o2 is ProviderState.Content) {
val o1Percent = o1.percentLowerThenBest
val o2Percent = o2.percentLowerThenBest
if (o1Percent is PercentLowerThanBest.Value && o2Percent !is PercentLowerThanBest.Value) {
if (o1Percent is PercentDifference.Value && o2Percent !is PercentDifference.Value) {
return -1
}
if (o1Percent !is PercentLowerThanBest.Value && o2Percent is PercentLowerThanBest.Value) {
if (o1Percent !is PercentDifference.Value && o2Percent is PercentDifference.Value) {
return 1
}
return if (o1Percent is PercentLowerThanBest.Value && o2Percent is PercentLowerThanBest.Value) {
o1Percent.value.compareTo(o2Percent.value)
return if (o1Percent is PercentDifference.Value && o2Percent is PercentDifference.Value) {
o2Percent.value.compareTo(o1Percent.value)
} else {
0
}

View file

@ -19,7 +19,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
import com.tangem.feature.swap.models.states.PercentLowerThanBest
import com.tangem.feature.swap.models.states.PercentDifference
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.presentation.R
import kotlinx.collections.immutable.toImmutableList
@ -114,7 +114,7 @@ private fun ChooseProviderBottomSheet_Preview() {
iconUrl = "",
subtitle = stringReference("1 000 000"),
additionalBadge = ProviderState.AdditionalBadge.BestTrade,
percentLowerThenBest = PercentLowerThanBest.Value(-1.0f),
percentLowerThenBest = PercentDifference.Value(-1.0f),
selectionType = ProviderState.SelectionType.SELECT,
namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = {},

View file

@ -26,7 +26,7 @@ import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.models.states.PercentLowerThanBest
import com.tangem.feature.swap.models.states.PercentDifference
import com.tangem.feature.swap.models.states.ProviderState
/**
@ -94,11 +94,8 @@ private fun ProviderContentState(
.padding(start = TangemTheme.dimens.spacing12)
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(state.iconUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
model = ImageRequest.Builder(context = LocalContext.current).data(state.iconUrl)
.crossfade(enable = true).allowHardware(false).build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
error = {
ErrorProviderIcon(
@ -138,10 +135,12 @@ private fun ProviderContentState(
)
}
when (state.additionalBadge) {
ProviderState.AdditionalBadge.BestTrade ->
BestTradeItem(Modifier.padding(start = TangemTheme.dimens.spacing4))
ProviderState.AdditionalBadge.PermissionRequired ->
PermissionBadgeItem(Modifier.padding(start = TangemTheme.dimens.spacing4))
ProviderState.AdditionalBadge.BestTrade -> BestTradeItem(
Modifier.padding(start = TangemTheme.dimens.spacing4),
)
ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem(
Modifier.padding(start = TangemTheme.dimens.spacing4),
)
ProviderState.AdditionalBadge.Empty -> {
// no-op
}
@ -162,14 +161,19 @@ private fun ProviderContentState(
maxLines = 1,
)
}
if (state.percentLowerThenBest is PercentLowerThanBest.Value &&
state.percentLowerThenBest.value > 0
if (state.percentLowerThenBest is PercentDifference.Value &&
state.percentLowerThenBest.value != 0f
) {
val textColor = if (state.percentLowerThenBest.value > 0) {
TangemTheme.colors.icon.accent
} else {
TangemTheme.colors.text.warning
}
AnimatedContent(targetState = state.percentLowerThenBest.value, label = "") {
Text(
text = "-$it%",
text = if (it > 0) "+$it%" else "$it%",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.warning,
color = textColor,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
@ -198,11 +202,8 @@ private fun ProviderUnavailableState(
.padding(start = TangemTheme.dimens.spacing12)
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(state.iconUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
model = ImageRequest.Builder(context = LocalContext.current).data(state.iconUrl)
.crossfade(enable = true).allowHardware(false).build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
error = {
ErrorProviderIcon(
@ -298,8 +299,7 @@ private fun ProviderLoadingState(modifier: Modifier = Modifier) {
@Composable
private fun BoxScope.ProviderChevron(selectionType: ProviderState.SelectionType, isSelected: Boolean) {
when (selectionType) {
ProviderState.SelectionType.NONE -> {
/* no-op */
ProviderState.SelectionType.NONE -> { /* no-op */
}
ProviderState.SelectionType.CLICK -> {
Icon(
@ -345,11 +345,10 @@ private fun BaseContainer(modifier: Modifier = Modifier, content: @Composable Bo
@Composable
private fun ErrorProviderIcon(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.background(
color = TangemTheme.colors.background.secondary,
shape = TangemTheme.shapes.roundedCorners8,
),
modifier = modifier.background(
color = TangemTheme.colors.background.secondary,
shape = TangemTheme.shapes.roundedCorners8,
),
contentAlignment = Alignment.Center,
) {
Icon(
@ -369,7 +368,7 @@ private fun BestTradeItem(modifier: Modifier = Modifier) {
),
) {
Text(
text = "Best rate",
text = stringResource(R.string.express_provider_best_rate),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),
@ -432,7 +431,7 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
iconUrl = "",
subtitle = stringReference(value = "0,64554846 DAI ≈ 1 MATIC"),
additionalBadge = ProviderState.AdditionalBadge.Empty,
percentLowerThenBest = PercentLowerThanBest.Empty,
percentLowerThenBest = PercentDifference.Value(value = 12.0f),
selectionType = ProviderState.SelectionType.SELECT,
namePrefix = ProviderState.PrefixType.PROVIDED_BY,
onProviderClick = {},
@ -440,7 +439,7 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
val contentState2 = contentState.copy(
subtitle = stringReference(value = "1 132,46 MATIC"),
additionalBadge = ProviderState.AdditionalBadge.PermissionRequired,
percentLowerThenBest = PercentLowerThanBest.Value(value = 5f),
percentLowerThenBest = PercentDifference.Value(value = 5f),
)
val unavailableState = ProviderState.Unavailable(
id = "1",

View file

@ -693,6 +693,32 @@ internal class StateBuilder(
)
}
fun updateSendCurrencyBalance(
uiState: SwapStateHolder,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): SwapStateHolder {
if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState
return uiState.copy(
sendCardData = uiState.sendCardData.copy(
balance = cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false),
),
)
}
fun updateReceiveCurrencyBalance(
uiState: SwapStateHolder,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): SwapStateHolder {
if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState
return uiState.copy(
receiveCardData = uiState.receiveCardData.copy(
balance = cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false),
),
)
}
fun updateBalanceHiddenState(uiState: SwapStateHolder, isBalanceHidden: Boolean): SwapStateHolder {
if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState
if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState
@ -998,6 +1024,7 @@ internal class StateBuilder(
fun showSelectProviderBottomSheet(
uiState: SwapStateHolder,
selectedProviderId: String,
bestRatedProviderId: String,
pricesLowerBest: Map<String, Float>,
providersStates: Map<SwapProvider, SwapState>,
unavailableProviders: List<SwapProvider>,
@ -1005,7 +1032,7 @@ internal class StateBuilder(
): SwapStateHolder {
val availableProvidersStates = providersStates.entries
.mapNotNull {
it.convertToProviderBottomSheetState(pricesLowerBest, actions.onProviderSelect)
it.convertToProviderBottomSheetState(pricesLowerBest, bestRatedProviderId, actions.onProviderSelect)
}
.sortedWith(ProviderPercentDiffComparator)
val unavailableProviderStates = unavailableProviders.map {
@ -1046,8 +1073,8 @@ internal class StateBuilder(
it.copy(
subtitle = stringReference(rateString),
percentLowerThenBest = pricesLowerBest[it.id]?.let { percent ->
PercentLowerThanBest.Value(percent)
} ?: PercentLowerThanBest.Empty,
PercentDifference.Value(percent)
} ?: PercentDifference.Value(0f),
)
} else {
it
@ -1093,7 +1120,7 @@ internal class StateBuilder(
},
readMoreUrl = buildReadMoreUrl(),
feeItems = txFeeState.toFeeItemState(),
readMore = resourceReference(R.string.common_fee_selector_link_description),
readMore = resourceReference(R.string.common_read_more),
onReadMoreClick = actions.onFeeReadMoreClick,
)
return uiState.copy(
@ -1153,18 +1180,21 @@ internal class StateBuilder(
private fun Map.Entry<SwapProvider, SwapState>.convertToProviderBottomSheetState(
pricesLowerBest: Map<String, Float>,
bestRatedProviderId: String,
onProviderSelect: (String) -> Unit,
): ProviderState? {
val provider = this.key
return when (val state = this.value) {
is SwapState.EmptyAmountState -> null
is SwapState.QuotesLoadedState -> provider.convertToContentSelectableProviderState(
isBestRate = false, // not show best rate in bottom sheet
state = state,
onProviderClick = onProviderSelect,
pricesLowerBest = pricesLowerBest,
selectionType = ProviderState.SelectionType.SELECT,
)
is SwapState.QuotesLoadedState -> {
provider.convertToContentSelectableProviderState(
isBestRate = bestRatedProviderId == provider.providerId,
state = state,
onProviderClick = onProviderSelect,
pricesLowerBest = pricesLowerBest,
selectionType = ProviderState.SelectionType.SELECT,
)
}
is SwapState.SwapError -> getProviderStateForError(
swapProvider = provider,
fromToken = state.fromTokenInfo.cryptoCurrencyStatus.currency,
@ -1211,7 +1241,7 @@ internal class StateBuilder(
private fun createNetworkFeeCoverageNotificationConfig(): NotificationConfig {
return NotificationConfig(
title = resourceReference(R.string.send_network_fee_warning_title),
subtitle = resourceReference(R.string.send_network_fee_warning_content),
subtitle = resourceReference(R.string.swapping_network_fee_warning_content),
iconResId = R.drawable.img_attention_20,
)
}
@ -1256,7 +1286,7 @@ internal class StateBuilder(
subtitle = stringReference(rateString),
additionalBadge = badge,
selectionType = selectionType,
percentLowerThenBest = PercentLowerThanBest.Empty,
percentLowerThenBest = PercentDifference.Empty,
namePrefix = ProviderState.PrefixType.PROVIDED_BY,
onProviderClick = onProviderClick,
)
@ -1287,8 +1317,8 @@ internal class StateBuilder(
additionalBadge = additionalBadge,
selectionType = selectionType,
percentLowerThenBest = pricesLowerBest[this.providerId]?.let { percent ->
PercentLowerThanBest.Value(percent)
} ?: PercentLowerThanBest.Value(0f),
PercentDifference.Value(percent)
} ?: PercentDifference.Value(0f),
namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = onProviderClick,
)
@ -1323,7 +1353,7 @@ internal class StateBuilder(
selectionType = selectionType,
subtitle = alertText,
additionalBadge = ProviderState.AdditionalBadge.Empty,
percentLowerThenBest = PercentLowerThanBest.Empty,
percentLowerThenBest = PercentDifference.Empty,
namePrefix = if (selectionType != ProviderState.SelectionType.SELECT) {
ProviderState.PrefixType.PROVIDED_BY
} else {

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.*
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
@ -13,11 +14,13 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.domain.BlockchainInteractor
import com.tangem.feature.swap.domain.SwapInteractor
@ -46,7 +49,6 @@ import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale
import javax.inject.Inject
import kotlin.math.absoluteValue
import kotlin.properties.Delegates
typealias SuccessLoadedSwapData = Map<SwapProvider, SwapState.QuotesLoadedState>
@ -62,6 +64,7 @@ internal class SwapViewModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase,
private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase,
private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase,
savedStateHandle: SavedStateHandle,
) : ViewModel(), DefaultLifecycleObserver {
@ -104,6 +107,9 @@ internal class SwapViewModel @Inject constructor(
(it.error is DataError.ExchangeTooSmallAmountError || it.error is DataError.ExchangeTooBigAmountError)
}
private val fromTokenBalanceJobHolder = JobHolder()
private val toTokenBalanceJobHolder = JobHolder()
val currentScreen: SwapNavScreen
get() = swapRouter.currentScreen
@ -173,6 +179,23 @@ internal class SwapViewModel @Inject constructor(
state,
),
)
val userWalletId = swapInteractor.getSelectedWallet()?.walletId ?: return@launch
(dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let {
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = it,
isFromCurrency = true,
)
}
(dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let {
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = it,
isFromCurrency = false,
)
}
}.onFailure {
Timber.tag(loggingTag).e(it)
@ -301,7 +324,7 @@ internal class SwapViewModel @Inject constructor(
val (provider, state) = updateLoadedQuotes(providersState)
setupLoadedState(provider, state, fromToken)
val successStates = providersState.getLastLoadedSuccessStates()
val pricesLowerBest = getPricesLowerBest(successStates)
val pricesLowerBest = getPricesLowerBest(provider.providerId, successStates)
uiState = stateBuilder.updateProvidersBottomSheetContent(
uiState = uiState,
pricesLowerBest = pricesLowerBest,
@ -617,7 +640,6 @@ internal class SwapViewModel @Inject constructor(
unavailable = unavailable,
afterSearch = true,
),
)
} else {
tokenDataState.copy(
@ -647,15 +669,35 @@ internal class SwapViewModel @Inject constructor(
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(tokenChosen = true, token = it))
}
val userWalletId = swapInteractor.getSelectedWallet()?.walletId
if (foundToken != null) {
val fromToken: CryptoCurrencyStatus
val toToken: CryptoCurrencyStatus
if (isOrderReversed) {
fromToken = foundToken.currencyStatus
toToken = initialCryptoCurrencyStatus
val newToken = fromToken.currency as? CryptoCurrency.Coin
if (userWalletId != null && newToken != null) {
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = newToken,
isFromCurrency = true,
)
}
} else {
fromToken = initialCryptoCurrencyStatus
toToken = foundToken.currencyStatus
val newToken = toToken.currency as? CryptoCurrency.Coin
if (userWalletId != null && newToken != null) {
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = newToken,
isFromCurrency = false,
)
}
}
dataState = dataState.copy(
fromCryptoCurrency = fromToken,
@ -673,6 +715,36 @@ internal class SwapViewModel @Inject constructor(
}
}
private fun subscribeToCoinBalanceUpdates(
userWalletId: UserWalletId,
coin: CryptoCurrency.Coin,
isFromCurrency: Boolean,
) {
Timber.d("Subscribe to ${coin.id} balance updates")
getCurrencyStatusUpdatesUseCase(
userWalletId = userWalletId,
currencyId = coin.id,
isSingleWalletWithTokens = false,
)
.mapNotNull { (it as? Either.Right)?.value }
.distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes
.onEach {
Timber.d("${coin.id} balance is ${it.value.amount}")
uiState = if (isFromCurrency) {
dataState = dataState.copy(fromCryptoCurrency = it)
stateBuilder.updateSendCurrencyBalance(uiState, it)
} else {
dataState = dataState.copy(toCryptoCurrency = it)
stateBuilder.updateReceiveCurrencyBalance(uiState, it)
}
}
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
.saveIn(if (isFromCurrency) fromTokenBalanceJobHolder else toTokenBalanceJobHolder)
}
private fun onChangeCardsClicked() {
val newFromToken = dataState.toCryptoCurrency
val newToToken = dataState.fromCryptoCurrency
@ -835,13 +907,14 @@ internal class SwapViewModel @Inject constructor(
onProviderClick = { providerId ->
analyticsEventHandler.send(SwapEvents.ProviderClicked)
val states = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates()
val pricesLowerBest = getPricesLowerBest(states)
val pricesLowerBest = getPricesLowerBest(providerId, states)
val unavailableProviders = getUnavailableProvidersFor(dataState.lastLoadedSwapStates)
uiState = stateBuilder.showSelectProviderBottomSheet(
uiState = uiState,
selectedProviderId = providerId,
pricesLowerBest = pricesLowerBest,
unavailableProviders = unavailableProviders,
bestRatedProviderId = findBestQuoteProvider(states)?.providerId ?: providerId,
providersStates = dataState.lastLoadedSwapStates,
) { uiState = stateBuilder.dismissBottomSheet(uiState) }
},
@ -950,17 +1023,18 @@ internal class SwapViewModel @Inject constructor(
}?.key
}
private fun getPricesLowerBest(state: SuccessLoadedSwapData): Map<String, Float> {
val bestRateEntry = state.maxByOrNull { it.value.toTokenInfo.tokenAmount.value } ?: return emptyMap()
val bestRate = bestRateEntry.value.toTokenInfo.tokenAmount.value
private fun getPricesLowerBest(selectedProviderId: String, state: SuccessLoadedSwapData): Map<String, Float> {
val selectedProviderEntry = state.filter { it.key.providerId == selectedProviderId }.entries.firstOrNull()
?: return emptyMap()
val selectedProviderRate = selectedProviderEntry.value.toTokenInfo.tokenAmount.value
val hundredPercent = BigDecimal("100")
return state.entries.mapNotNull {
if (it.key != bestRateEntry.key) {
if (it.key != selectedProviderEntry.key) {
val amount = it.value.toTokenInfo.tokenAmount.value
val percentDiff = BigDecimal.ONE.minus(
amount.divide(bestRate, RoundingMode.HALF_UP),
selectedProviderRate.divide(amount, RoundingMode.HALF_UP),
).multiply(hundredPercent)
it.key.providerId to percentDiff.setScale(2, RoundingMode.HALF_UP).toFloat().absoluteValue
it.key.providerId to percentDiff.setScale(2, RoundingMode.HALF_UP).toFloat()
} else {
null
}

View file

@ -2,4 +2,8 @@ plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.tester.api"
}

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.features.tester.api" />

View file

@ -6,6 +6,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.tester.impl"
}
dependencies {
/** AndroidX */
implementation(deps.androidx.activity.compose)

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.tangem.feature.tester.impl">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity

View file

@ -99,6 +99,7 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
private val networkName: String,
private val feeCurrencyName: String,
private val feeCurrencySymbol: String,
val mergeFeeNetworkName: Boolean = false,
private val onBuyClick: () -> Unit,
) : Warning(
title = TextReference.Res(
@ -120,7 +121,13 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(
id = R.string.common_buy_currency,
formatArgs = wrappedList(feeCurrencySymbol),
formatArgs = wrappedList(
if (mergeFeeNetworkName) {
"$feeCurrencyName ($feeCurrencySymbol)"
} else {
feeCurrencySymbol
},
),
),
onClick = onBuyClick,
),

View file

@ -4,6 +4,7 @@ import arrow.core.Either
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
@ -18,7 +19,6 @@ import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
internal class TokenDetailsLoadedBalanceConverter(
private val currentStateProvider: Provider<TokenDetailsState>,
@ -118,9 +118,7 @@ internal class TokenDetailsLoadedBalanceConverter(
}
private fun getPriceChangeType(status: CryptoCurrencyStatus.Status): PriceChangeType {
val priceChange = status.priceChange ?: return PriceChangeType.DOWN
return if (priceChange > BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN
return PriceChangeConverter.fromBigDecimal(status.priceChange)
}
private fun formatPriceChange(status: CryptoCurrencyStatus.Status): String {

View file

@ -1,5 +1,8 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification
@ -28,9 +31,10 @@ internal class TokenDetailsNotificationConverter(
return when (warning) {
is CryptoCurrencyWarning.BalanceNotEnoughForFee -> NetworkFeeWithBuyButton(
currency = warning.tokenCurrency,
networkName = warning.coinCurrency.name,
networkName = warning.coinCurrency.network.name,
feeCurrencyName = warning.coinCurrency.name,
feeCurrencySymbol = warning.coinCurrency.symbol,
mergeFeeNetworkName = warning.coinCurrency.shouldMergeFeeNetworkName(),
onBuyClick = { clickIntents.onBuyCoinClick(warning.coinCurrency) },
)
is CryptoCurrencyWarning.CustomTokenNotEnoughForFee -> {
@ -41,6 +45,7 @@ internal class TokenDetailsNotificationConverter(
networkName = feeCurrency.network.name,
feeCurrencyName = warning.feeCurrencyName,
feeCurrencySymbol = warning.feeCurrencySymbol,
mergeFeeNetworkName = warning.currency.shouldMergeFeeNetworkName(),
onBuyClick = { clickIntents.onBuyCoinClick(feeCurrency) },
)
} else {
@ -75,4 +80,9 @@ internal class TokenDetailsNotificationConverter(
)
}
}
// workaround for networks that users have misunderstanding
private fun CryptoCurrency.shouldMergeFeeNetworkName(): Boolean {
return Blockchain.fromNetworkId(this.network.backendId) == Blockchain.Arbitrum
}
}

View file

@ -43,6 +43,7 @@ import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.swap.domain.SwapTransactionRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsCurrencyStatusAnalyticsSender
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsNotificationsAnalyticsSender
@ -84,6 +85,7 @@ internal class TokenDetailsViewModel @Inject constructor(
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val shouldShowSwapPromoTokenUseCase: ShouldShowSwapPromoTokenUseCase,
private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase,
private val swapRepository: SwapRepository,
private val swapTransactionRepository: SwapTransactionRepository,
private val quotesRepository: QuotesRepository,
@ -301,6 +303,9 @@ internal class TokenDetailsViewModel @Inject constructor(
val config = uiState.bottomSheetConfig
val exchangeBottomSheet = config?.content as? ExchangeStatusBottomSheetConfig
val currentTx = swapTxs.firstOrNull { it.txId == exchangeBottomSheet?.value?.txId }
if (currentTx?.activeStatus == ExchangeStatus.Finished) {
updateNetworkToSwapBalance(currentTx.toCryptoCurrency)
}
uiState = uiState.copy(
swapTxs = swapTxs,
bottomSheetConfig = currentTx?.let(
@ -309,6 +314,16 @@ internal class TokenDetailsViewModel @Inject constructor(
)
}
private fun updateNetworkToSwapBalance(toCryptoCurrency: CryptoCurrency) {
viewModelScope.launch {
updateDelayedCurrencyStatusUseCase(
userWalletId = userWalletId,
network = toCryptoCurrency.network,
refresh = true,
)
}
}
/**
* @param refresh - invalidate cache and get data from remote
* @param showItemsLoading - show loading items placeholder.

View file

@ -4,6 +4,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.features.wallet.api"
}
dependencies {
/** AndroidX */
implementation(deps.androidx.fragment.ktx)

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.features.wallet.api" />

View file

@ -6,6 +6,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.wallet.impl"
}
dependencies {
/** AndroidX */
implementation(deps.androidx.activity.compose)

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.tangem.feature.wallet.impl">
</manifest>

View file

@ -87,11 +87,13 @@ private fun PriceChangeIcon(type: PriceChangeType) {
id = when (animatedType) {
PriceChangeType.UP -> R.drawable.ic_arrow_up_8
PriceChangeType.DOWN -> R.drawable.ic_arrow_down_8
PriceChangeType.NEUTRAL -> R.drawable.ic_elipse_8
},
),
tint = when (animatedType) {
PriceChangeType.UP -> TangemTheme.colors.icon.accent
PriceChangeType.DOWN -> TangemTheme.colors.icon.warning
PriceChangeType.NEUTRAL -> TangemTheme.colors.icon.inactive
},
contentDescription = null,
)
@ -106,6 +108,7 @@ private fun PriceChangeText(type: PriceChangeType?, text: String?, modifier: Mod
color = when (type) {
PriceChangeType.UP -> TangemTheme.colors.text.accent
PriceChangeType.DOWN -> TangemTheme.colors.text.warning
PriceChangeType.NEUTRAL -> TangemTheme.colors.text.disabled
null -> TangemTheme.colors.text.tertiary
},
overflow = TextOverflow.Ellipsis,

View file

@ -3,11 +3,11 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class SingleWalletMarketPriceConverter(
private val status: CryptoCurrencyStatus.Status,
@ -61,8 +61,6 @@ internal class SingleWalletMarketPriceConverter(
}
private fun getPriceChangeType(status: CryptoCurrencyStatus.Status): PriceChangeType {
val priceChange = status.priceChange ?: return PriceChangeType.DOWN
return if (priceChange > BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN
return PriceChangeConverter.fromBigDecimal(status.priceChange)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
@ -97,8 +98,6 @@ internal class TokenItemStateConverter(
priceChangePercent = BigDecimalFormatter.formatPercent(
percent = priceChange,
useAbsoluteValue = true,
maxFractionDigits = 1,
minFractionDigits = 1,
),
type = priceChange.getPriceChangeType(),
)
@ -117,6 +116,6 @@ internal class TokenItemStateConverter(
}
private fun BigDecimal.getPriceChangeType(): PriceChangeType {
return if (this > BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN
return PriceChangeConverter.fromBigDecimal(value = this)
}
}

View file

@ -1,7 +1,11 @@
package com.tangem.feature.wallet.presentation.wallet.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
@ -13,16 +17,19 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.paging.compose.collectAsLazyPagingItems
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.tangem.core.ui.components.Keyboard
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.components.atoms.Hand
import com.tangem.core.ui.components.atoms.handComposableComponentHeight
import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBottomSheet
@ -64,6 +71,13 @@ internal fun WalletScreen(
val snackbarHostState = remember(::SnackbarHostState)
val isAutoScroll = remember { mutableStateOf(value = false) }
var alertConfig by remember { mutableStateOf<WalletAlertState?>(value = null) }
val config = alertConfig
if (config != null) {
WalletAlert(state = config, onDismiss = { alertConfig = null })
}
WalletContent(
state = state,
walletsListState = walletsListState,
@ -72,14 +86,9 @@ internal fun WalletScreen(
onAutoScrollReset = { isAutoScroll.value = false },
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
bottomSheetContent = bottomSheetContent,
alertConfig = alertConfig,
)
var alertConfig by remember { mutableStateOf<WalletAlertState?>(value = null) }
alertConfig?.let {
WalletAlert(state = it, onDismiss = { alertConfig = null })
}
WalletEventEffect(
event = state.event,
selectedWalletIndex = state.selectedWalletIndex,
@ -100,8 +109,9 @@ private fun WalletContent(
bottomSheetHeaderHeightProvider: () -> Dp,
onAutoScrollReset: () -> Unit,
bottomSheetContent: @Composable () -> Unit,
alertConfig: WalletAlertState?,
) {
var selectedWalletIndex by remember { mutableIntStateOf(state.selectedWalletIndex) }
var selectedWalletIndex by remember(state.selectedWalletIndex) { mutableIntStateOf(state.selectedWalletIndex) }
val selectedWallet = state.wallets[selectedWalletIndex]
val scaffoldContent: @Composable () -> Unit = {
@ -207,6 +217,7 @@ private fun WalletContent(
snackbarHostState = snackbarHostState,
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
bottomSheetContent = bottomSheetContent,
alertConfig = alertConfig,
) {
scaffoldContent()
}
@ -222,7 +233,7 @@ private fun WalletContent(
}
@Suppress("LongParameterList", "LongMethod")
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun BaseScaffoldManageTokenRedesign(
state: WalletScreenState,
@ -230,49 +241,46 @@ private fun BaseScaffoldManageTokenRedesign(
snackbarHostState: SnackbarHostState,
bottomSheetHeaderHeightProvider: () -> Dp,
bottomSheetContent: @Composable () -> Unit,
alertConfig: WalletAlertState?,
content: @Composable () -> Unit,
) {
val scaffoldState = rememberBottomSheetScaffoldState()
// show the bottom sheet if there is at least one multicurrency wallet
val showManageTokensBottomSheet = remember(state.wallets) {
state.wallets.any { it is WalletState.MultiCurrency }
}
val bottomSheetState = rememberSheetStateEnhanced(
initialValue = if (showManageTokensBottomSheet) SheetValue.PartiallyExpanded else SheetValue.Hidden,
confirmValueChange = { sheetValue ->
when {
sheetValue == SheetValue.Hidden && showManageTokensBottomSheet -> false
sheetValue != SheetValue.Hidden && !showManageTokensBottomSheet -> false
else -> true
}
},
skipHiddenState = showManageTokensBottomSheet,
)
val keyboardShown = keyboardAsState()
BottomSheetStateEffects(
bottomSheetState = bottomSheetState,
showManageTokensBottomSheet = showManageTokensBottomSheet,
alertConfig = alertConfig,
keyboardShown = keyboardShown,
)
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = bottomSheetState,
snackbarHostState = snackbarHostState,
)
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
val statusBarHeight = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() }
val systemUiController = rememberSystemUiController()
val navigationBarColor = TangemTheme.colors.background.primary
val navigationBarColorWithout = TangemTheme.colors.background.secondary
DisposableEffect(
navigationBarColor,
navigationBarColorWithout,
) {
systemUiController.setNavigationBarColor(navigationBarColor)
onDispose {
systemUiController.setNavigationBarColor(navigationBarColorWithout)
}
}
val keyboardShown by keyboardAsState()
// expand bottom sheet when keyboard appears
LaunchedEffect(keyboardShown is Keyboard.Opened) {
if (keyboardShown is Keyboard.Opened) {
scaffoldState.bottomSheetState.expand()
}
}
val keyboardController = LocalSoftwareKeyboardController.current
val sheetHasBeenHidden = scaffoldState.bottomSheetState.targetValue == SheetValue.PartiallyExpanded
// hide keyboard when bottom sheet is about to be hidden
LaunchedEffect(sheetHasBeenHidden) {
if (sheetHasBeenHidden) {
keyboardController?.hide()
}
}
val peekHeight = bottomSheetHeaderHeightProvider() + handComposableComponentHeight + bottomBarHeight
val coroutineScope = rememberCoroutineScope()
BottomSheetScaffold(
topBar = {
WalletTopBar(config = state.topBarConfig)
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
@ -296,10 +304,10 @@ private fun BaseScaffoldManageTokenRedesign(
// hide bottom sheet when back pressed
BackHandler(
keyboardShown is Keyboard.Closed &&
scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded,
keyboardShown.value is Keyboard.Closed &&
bottomSheetState.currentValue == SheetValue.Expanded,
) {
coroutineScope.launch { scaffoldState.bottomSheetState.partialExpand() }
coroutineScope.launch { bottomSheetState.partialExpand() }
}
},
content = { paddingValues ->
@ -308,23 +316,153 @@ private fun BaseScaffoldManageTokenRedesign(
onRefresh = selectedWallet.pullToRefreshConfig.onRefresh,
)
Box(
modifier = Modifier
.pullRefresh(pullRefreshState)
.padding(paddingValues),
Column(
modifier = Modifier.padding(paddingValues),
) {
content()
WalletTopBar(config = state.topBarConfig)
Box(
modifier = Modifier.pullRefresh(pullRefreshState),
) {
content()
WalletPullToRefreshIndicator(
isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
)
WalletPullToRefreshIndicator(
isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
BottomSheetScrim(
color = BottomSheetDefaults.ScrimColor,
visible = bottomSheetState.targetValue == SheetValue.Expanded,
onDismissRequest = { coroutineScope.launch { bottomSheetState.partialExpand() } },
)
},
)
}
@Composable
private fun BottomSheetScrim(color: Color, visible: Boolean, onDismissRequest: () -> Unit) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = TweenSpec(),
label = "scrim",
)
val dismissSheet = if (visible) {
Modifier
.pointerInput(onDismissRequest) {
detectTapGestures {
onDismissRequest()
}
}
.clearAndSetSemantics {}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissSheet),
) {
drawRect(color = color, alpha = alpha)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BottomSheetStateEffects(
bottomSheetState: SheetState,
showManageTokensBottomSheet: Boolean,
alertConfig: WalletAlertState?,
keyboardShown: State<Keyboard>,
) {
// Bottom sheet during initialization internally expand partially after its content was remeasured,
// therefore initialValue = SheetValue.Hidden in rememberStandardBottomSheetState doesn't work as expected
// so we have to manually restrict expansion in this case
LaunchedEffect(bottomSheetState.targetValue, bottomSheetState.currentValue) {
if (!showManageTokensBottomSheet &&
(bottomSheetState.targetValue != SheetValue.Hidden || bottomSheetState.currentValue != SheetValue.Hidden)
) {
bottomSheetState.hide()
}
}
// react to changes in wallet list
LaunchedEffect(showManageTokensBottomSheet) {
when {
showManageTokensBottomSheet && bottomSheetState.currentValue != SheetValue.PartiallyExpanded -> {
bottomSheetState.partialExpand()
}
!showManageTokensBottomSheet && bottomSheetState.targetValue != SheetValue.Hidden -> {
bottomSheetState.hide()
}
}
}
val systemUiController = rememberSystemUiController()
val navigationBarColor = TangemTheme.colors.background.primary
val navigationBarColorWithout = TangemTheme.colors.background.secondary
SystemBarsEffect {
if (showManageTokensBottomSheet) {
setNavigationBarColor(navigationBarColor)
}
}
DisposableEffect(
showManageTokensBottomSheet,
) {
onDispose {
if (showManageTokensBottomSheet) {
systemUiController.setNavigationBarColor(navigationBarColorWithout)
}
}
}
// expand bottom sheet when keyboard appears
LaunchedEffect(keyboardShown.value is Keyboard.Opened) {
if (keyboardShown.value is Keyboard.Opened && alertConfig == null) {
bottomSheetState.expand()
}
}
val keyboardController = LocalSoftwareKeyboardController.current
// hide keyboard when bottom sheet is about to be hidden
LaunchedEffect(Unit) {
snapshotFlow {
bottomSheetState.currentValue == SheetValue.Expanded &&
bottomSheetState.targetValue == SheetValue.PartiallyExpanded
}.collect { sheetHasBeenHidden ->
if (sheetHasBeenHidden) {
keyboardController?.hide()
}
}
}
}
/**
* Use a standard method when this is fixed https://issuetracker.google.com/issues/314796718
* Current material3 version: 1.2.0
*/
@Composable
@ExperimentalMaterial3Api
private fun rememberSheetStateEnhanced(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
initialValue: SheetValue = SheetValue.Hidden,
skipHiddenState: Boolean = false,
): SheetState {
val density = LocalDensity.current
return remember(initialValue, skipPartiallyExpanded, confirmValueChange, skipHiddenState) {
SheetState(
skipPartiallyExpanded = skipPartiallyExpanded,
density = density,
initialValue = initialValue,
confirmValueChange = confirmValueChange,
skipHiddenState = skipHiddenState,
)
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun BaseScaffold(

View file

@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.snapshotFlow
import com.tangem.feature.wallet.presentation.wallet.ui.utils.LazyListItemData
import com.tangem.feature.wallet.presentation.wallet.ui.utils.ScrollOffsetCollector
import com.tangem.feature.wallet.presentation.wallet.ui.utils.WalletsListInteractionsCollector
@ -19,7 +20,11 @@ internal fun WalletsListEffects(
onAutoScrollReset: () -> Unit,
) {
LaunchedEffect(key1 = lazyListState, key2 = onWalletChange) {
snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo }
snapshotFlow {
lazyListState.layoutInfo.visibleItemsInfo.map {
LazyListItemData(it.index, it.size, it.offset)
}
}
.collect(
collector = ScrollOffsetCollector(
selectedWalletIndex = selectedWalletIndex,

View file

@ -0,0 +1,405 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.withContext
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.sign
@ExperimentalFoundationApi
class TangemSnapFlingBehavior(
private val snapLayoutInfoProvider: SnapLayoutInfoProvider,
private val lowVelocityAnimationSpec: AnimationSpec<Float>,
private val highVelocityAnimationSpec: DecayAnimationSpec<Float>,
private val snapAnimationSpec: AnimationSpec<Float>,
private val density: Density,
private val shortSnapVelocityThreshold: Dp = MinFlingVelocityDp,
) : FlingBehavior {
private val velocityThreshold = with(density) { shortSnapVelocityThreshold.toPx() }
private var motionScaleDuration = DefaultScrollMotionDurationScale
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
return performFling(initialVelocity) {}
}
/**
* Perform a snapping fling animation with given velocity and suspend until fling has
* finished. This will behave the same way as [performFling] except it will report on
* each remainingOffsetUpdate using the [onSettlingDistanceUpdated] lambda.
*
* @param initialVelocity velocity available for fling in the orientation specified in
* [androidx.compose.foundation.gestures.scrollable] that invoked this method.
*
* @param onSettlingDistanceUpdated a lambda that will be called anytime the
* distance to the settling offset is updated. The settling offset is the final offset where
* this fling will stop and may change depending on the snapping animation progression.
*
* @return remaining velocity after fling operation has ended
*/
private suspend fun ScrollScope.performFling(
initialVelocity: Float,
onSettlingDistanceUpdated: (Float) -> Unit,
): Float {
val (remainingOffset, remainingState) = fling(initialVelocity, onSettlingDistanceUpdated)
// No remaining offset means we've used everything, no need to propagate velocity. Otherwise
// we couldn't use everything (probably because we have hit the min/max bounds of the
// containing layout) we should propagate the offset.
return if (remainingOffset == 0f) NoVelocity else remainingState.velocity
}
private suspend fun ScrollScope.fling(
initialVelocity: Float,
onRemainingScrollOffsetUpdate: (Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
// If snapping from scroll (short snap) or fling (long snap)
val result = withContext(motionScaleDuration) {
if (abs(initialVelocity) <= abs(velocityThreshold)) {
shortSnap(initialVelocity, onRemainingScrollOffsetUpdate)
} else {
longSnap(initialVelocity, onRemainingScrollOffsetUpdate)
}
}
onRemainingScrollOffsetUpdate(0f) // Animation finished or was cancelled
return result
}
private suspend fun ScrollScope.shortSnap(
velocity: Float,
onRemainingScrollOffsetUpdate: (Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
val closestOffset = with(snapLayoutInfoProvider) {
density.calculateSnappingOffset(0f)
}
var remainingScrollOffset = closestOffset
val animationState = AnimationState(NoDistance, velocity)
return animateSnap(
closestOffset,
closestOffset,
animationState,
snapAnimationSpec,
) { delta ->
remainingScrollOffset -= delta
onRemainingScrollOffsetUpdate(remainingScrollOffset)
}
}
private suspend fun ScrollScope.longSnap(
initialVelocity: Float,
onAnimationStep: (remainingScrollOffset: Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
val initialOffset =
with(snapLayoutInfoProvider) { density.calculateApproachOffset(initialVelocity) }.let {
abs(it) * sign(initialVelocity) // ensure offset sign is correct
}
var remainingScrollOffset = initialOffset
onAnimationStep(remainingScrollOffset) // First Scroll Offset
val (remainingOffset, animationState) = runApproach(
initialOffset,
initialVelocity,
) { delta ->
remainingScrollOffset -= delta
onAnimationStep(remainingScrollOffset)
}
remainingScrollOffset = remainingOffset
return animateSnap(
remainingOffset,
remainingOffset,
animationState.copy(value = 0f),
snapAnimationSpec,
) { delta ->
remainingScrollOffset -= delta
onAnimationStep(remainingScrollOffset)
}
}
private suspend fun ScrollScope.runApproach(
initialTargetOffset: Float,
initialVelocity: Float,
onAnimationStep: (delta: Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
val animation =
if (isDecayApproachPossible(offset = initialTargetOffset, velocity = initialVelocity)) {
HighVelocityApproachAnimation(highVelocityAnimationSpec)
} else {
LowVelocityApproachAnimation(
lowVelocityAnimationSpec,
snapLayoutInfoProvider,
density,
)
}
return approach(
initialTargetOffset,
initialVelocity,
animation,
snapLayoutInfoProvider,
density,
onAnimationStep,
)
}
/**
* If we can approach the target and still have velocity left
*/
private fun isDecayApproachPossible(offset: Float, velocity: Float): Boolean {
val decayOffset = highVelocityAnimationSpec.calculateTargetValue(NoDistance, velocity)
val snapStepSize = with(snapLayoutInfoProvider) { density.calculateSnapStepSize() }
return decayOffset.absoluteValue >= offset.absoluteValue + snapStepSize
}
override fun equals(other: Any?): Boolean {
return if (other is TangemSnapFlingBehavior) {
other.snapAnimationSpec == this.snapAnimationSpec &&
other.highVelocityAnimationSpec == this.highVelocityAnimationSpec &&
other.lowVelocityAnimationSpec == this.lowVelocityAnimationSpec &&
other.snapLayoutInfoProvider == this.snapLayoutInfoProvider &&
other.density == this.density &&
other.shortSnapVelocityThreshold == this.shortSnapVelocityThreshold
} else {
false
}
}
override fun hashCode(): Int = 0
.let { 31 * it + snapAnimationSpec.hashCode() }
.let { 31 * it + highVelocityAnimationSpec.hashCode() }
.let { 31 * it + lowVelocityAnimationSpec.hashCode() }
.let { 31 * it + snapLayoutInfoProvider.hashCode() }
.let { 31 * it + density.hashCode() }
.let { 31 * it + shortSnapVelocityThreshold.hashCode() }
}
@Suppress("LongParameterList")
@OptIn(ExperimentalFoundationApi::class)
private suspend fun ScrollScope.approach(
initialTargetOffset: Float,
initialVelocity: Float,
animation: ApproachAnimation<Float, AnimationVector1D>,
snapLayoutInfoProvider: SnapLayoutInfoProvider,
density: Density,
onAnimationStep: (delta: Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
val (_, currentAnimationState) = animation.approachAnimation(
this,
initialTargetOffset,
initialVelocity,
onAnimationStep,
)
val remainingOffset = with(snapLayoutInfoProvider) {
density.calculateSnappingOffset(currentAnimationState.velocity)
}
// will snap the remainder
return AnimationResult(remainingOffset, currentAnimationState)
}
/**
* Runs a [AnimationSpec] to snap the list into [targetOffset]. Uses [cancelOffset] to stop this
* animation before it reaches the target.
*
* @param targetOffset The final target of this animation
* @param cancelOffset If we'd like to finish the animation earlier we use this value
* @param animationState The current animation state for continuation purposes
* @param snapAnimationSpec The [AnimationSpec] that will drive this animation
* @param onAnimationStep Called for each new scroll delta emitted by the animation cycle.
*/
@Suppress("MagicNumber")
private suspend fun ScrollScope.animateSnap(
targetOffset: Float,
cancelOffset: Float,
animationState: AnimationState<Float, AnimationVector1D>,
snapAnimationSpec: AnimationSpec<Float>,
onAnimationStep: (delta: Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
var consumedUpToNow = 0f
val initialVelocity = animationState.velocity
animationState.animateTo(
targetOffset,
animationSpec = snapAnimationSpec,
sequentialAnimation = animationState.velocity != 0f,
) {
val realValue = value.coerceToTarget(cancelOffset)
val delta = realValue - consumedUpToNow
val consumed = scrollBy(delta)
onAnimationStep(consumed)
// stop when unconsumed or when we reach the desired value
if (abs(delta - consumed) > 0.5f || realValue != value) {
cancelAnimation()
}
consumedUpToNow += consumed
}
// Always course correct velocity so they don't become too large.
val finalVelocity = animationState.velocity.coerceToTarget(initialVelocity)
return AnimationResult(
targetOffset - consumedUpToNow,
animationState.copy(velocity = finalVelocity),
)
}
private class HighVelocityApproachAnimation(
private val decayAnimationSpec: DecayAnimationSpec<Float>,
) : ApproachAnimation<Float, AnimationVector1D> {
override suspend fun approachAnimation(
scope: ScrollScope,
offset: Float,
velocity: Float,
onAnimationStep: (delta: Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
val animationState = AnimationState(initialValue = 0f, initialVelocity = velocity)
return with(scope) {
animateDecay(offset, animationState, decayAnimationSpec, onAnimationStep)
}
}
}
private class LowVelocityApproachAnimation @OptIn(ExperimentalFoundationApi::class) constructor(
private val lowVelocityAnimationSpec: AnimationSpec<Float>,
private val layoutInfoProvider: SnapLayoutInfoProvider,
private val density: Density,
) : ApproachAnimation<Float, AnimationVector1D> {
@OptIn(ExperimentalFoundationApi::class)
override suspend fun approachAnimation(
scope: ScrollScope,
offset: Float,
velocity: Float,
onAnimationStep: (delta: Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
val animationState = AnimationState(initialValue = 0f, initialVelocity = velocity)
val targetOffset =
(abs(offset) + with(layoutInfoProvider) { density.calculateSnapStepSize() }) * sign(
velocity,
)
return with(scope) {
animateSnap(
targetOffset = targetOffset,
cancelOffset = offset,
animationState = animationState,
snapAnimationSpec = lowVelocityAnimationSpec,
onAnimationStep = onAnimationStep,
)
}
}
}
@Suppress("MagicNumber")
private suspend fun ScrollScope.animateDecay(
targetOffset: Float,
animationState: AnimationState<Float, AnimationVector1D>,
decayAnimationSpec: DecayAnimationSpec<Float>,
onAnimationStep: (delta: Float) -> Unit,
): AnimationResult<Float, AnimationVector1D> {
var previousValue = 0f
fun AnimationScope<Float, AnimationVector1D>.consumeDelta(delta: Float) {
val consumed = scrollBy(delta)
onAnimationStep(consumed)
if (abs(delta - consumed) > 0.5f) cancelAnimation()
}
animationState.animateDecay(
animationSpec = decayAnimationSpec,
sequentialAnimation = animationState.velocity != 0f,
) {
previousValue = if (abs(value) >= abs(targetOffset)) {
val finalValue = value.coerceToTarget(targetOffset)
val finalDelta = finalValue - previousValue
consumeDelta(finalDelta)
cancelAnimation()
finalValue
} else {
val delta = value - previousValue
consumeDelta(delta)
value
}
}
return AnimationResult(
targetOffset - previousValue,
animationState,
)
}
private interface ApproachAnimation<T, V : AnimationVector> {
suspend fun approachAnimation(
scope: ScrollScope,
offset: T,
velocity: T,
onAnimationStep: (delta: T) -> Unit,
): AnimationResult<T, V>
}
private fun Float.coerceToTarget(target: Float): Float {
if (target == 0f) return 0f
return if (target > 0) coerceAtMost(target) else coerceAtLeast(target)
}
private class AnimationResult<T, V : AnimationVector>(
val remainingOffset: T,
val currentAnimationState: AnimationState<T, V>,
) {
operator fun component1(): T = remainingOffset
operator fun component2(): AnimationState<T, V> = currentAnimationState
}
@Suppress("TopLevelPropertyNaming")
private const val DefaultScrollMotionDurationScaleFactor = 1f
@Suppress("TopLevelPropertyNaming")
val DefaultScrollMotionDurationScale = object : MotionDurationScale {
override val scaleFactor: Float
get() = DefaultScrollMotionDurationScaleFactor
}
@Suppress("TopLevelPropertyNaming")
internal val MinFlingVelocityDp = 400.dp
@Suppress("TopLevelPropertyNaming")
internal const val NoDistance = 0f
@Suppress("TopLevelPropertyNaming")
internal const val NoVelocity = 0f
@ExperimentalFoundationApi
interface SnapLayoutInfoProvider {
/**
* The minimum offset that snapping will use to animate.(e.g. an item size)
*/
fun Density.calculateSnapStepSize(): Float
/**
* Calculate the distance to navigate before settling into the next snapping bound.
*
* @param initialVelocity The current fling movement velocity. You can use this tho calculate a
* velocity based offset.
*/
fun Density.calculateApproachOffset(initialVelocity: Float): Float
/**
* Given a target placement in a layout, the snapping offset is the next snapping position
* this layout can be placed in. If this is a short snapping, [currentVelocity] is guaranteed
* to be 0.If it is a long snapping, this method will be called
* after [calculateApproachOffset].
*
* @param currentVelocity The current fling movement velocity. This may change throughout the
* fling animation.
*/
fun Density.calculateSnappingOffset(currentVelocity: Float): Float
}

View file

@ -0,0 +1,177 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.calculateTargetValue
import androidx.compose.animation.splineBasedDecay
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.snapping.SnapFlingBehavior
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.unit.Density
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.sign
/**
* A [SnapLayoutInfoProvider] for LazyLists.
*
* @param lazyListState The [LazyListState] with information about the current state of the list
* @param positionInLayout The desired positioning of the snapped item within the main layout.
* This position should be considered with regard to the start edge of the item and the placement
* within the viewport.
*
* @return A [SnapLayoutInfoProvider] that can be used with [SnapFlingBehavior]
*/
@Suppress("FunctionNaming")
@ExperimentalFoundationApi
fun TangemSnapLayoutInfoProvider(
lazyListState: LazyListState,
positionInLayout: SnapPositionInLayout = SnapPositionInLayout.CenterToCenter,
): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider {
private val layoutInfo: LazyListLayoutInfo
get() = lazyListState.layoutInfo
// Decayed page snapping is the default
override fun Density.calculateApproachOffset(initialVelocity: Float): Float {
val decayAnimationSpec: DecayAnimationSpec<Float> = splineBasedDecay(this)
val offset =
decayAnimationSpec.calculateTargetValue(NoDistance, initialVelocity).absoluteValue
val finalDecayOffset = (offset - calculateSnapStepSize()).coerceAtLeast(0f)
return if (finalDecayOffset == 0f) {
finalDecayOffset
} else {
finalDecayOffset * initialVelocity.sign
}
}
override fun Density.calculateSnappingOffset(currentVelocity: Float): Float {
var lowerBoundOffset = Float.NEGATIVE_INFINITY
var upperBoundOffset = Float.POSITIVE_INFINITY
layoutInfo.visibleItemsInfo.fastForEach { item ->
val offset =
calculateDistanceToDesiredSnapPosition(
mainAxisViewPortSize = layoutInfo.singleAxisViewportSize,
beforeContentPadding = layoutInfo.beforeContentPadding,
afterContentPadding = layoutInfo.afterContentPadding,
itemSize = item.size,
itemOffset = item.offset,
itemIndex = item.index,
snapPositionInLayout = positionInLayout,
)
// Find item that is closest to the center
if (offset <= 0 && offset > lowerBoundOffset) {
lowerBoundOffset = offset
}
// Find item that is closest to center, but after it
if (offset >= 0 && offset < upperBoundOffset) {
upperBoundOffset = offset
}
}
return calculateFinalOffset(currentVelocity, lowerBoundOffset, upperBoundOffset)
}
override fun Density.calculateSnapStepSize(): Float = with(layoutInfo) {
if (visibleItemsInfo.isNotEmpty()) {
visibleItemsInfo.fastSumBy { it.size } / visibleItemsInfo.size.toFloat()
} else {
0f
}
}
}
@Suppress("BanInlineOptIn")
@OptIn(ExperimentalContracts::class)
inline fun <T> List<T>.fastSumBy(selector: (T) -> Int): Int {
contract { callsInPlace(selector) }
var sum = 0
fastForEach { element ->
sum += selector(element)
}
return sum
}
internal fun calculateFinalOffset(velocity: Float, lowerBound: Float, upperBound: Float): Float {
fun Float.isValidDistance(): Boolean {
return this != Float.POSITIVE_INFINITY && this != Float.NEGATIVE_INFINITY
}
val finalDistance = when (sign(velocity)) {
0f -> {
if (abs(upperBound) <= abs(lowerBound)) {
upperBound
} else {
lowerBound
}
}
1f -> upperBound
-1f -> lowerBound
else -> NoDistance
}
return if (finalDistance.isValidDistance()) {
finalDistance
} else {
NoDistance
}
}
internal val LazyListLayoutInfo.singleAxisViewportSize: Int
get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width
@Suppress("BanInlineOptIn")
@OptIn(ExperimentalContracts::class)
inline fun <T> List<T>.fastForEach(action: (T) -> Unit) {
contract { callsInPlace(action) }
for (index in indices) {
val item = get(index)
action(item)
}
}
@Suppress("LongParameterList")
@OptIn(ExperimentalFoundationApi::class)
internal fun Density.calculateDistanceToDesiredSnapPosition(
mainAxisViewPortSize: Int,
beforeContentPadding: Int,
afterContentPadding: Int,
itemSize: Int,
itemOffset: Int,
itemIndex: Int,
snapPositionInLayout: SnapPositionInLayout,
): Float {
val containerSize = mainAxisViewPortSize - beforeContentPadding - afterContentPadding
val desiredDistance = with(snapPositionInLayout) {
position(containerSize, itemSize, itemIndex)
}.toFloat()
return itemOffset - desiredDistance
}
@ExperimentalFoundationApi
fun interface SnapPositionInLayout {
/**
* Calculates an offset positioning between a container and an element within this container.
* The offset calculation is the necessary diff that should be applied to the item offset to
* align the item with a position within the container. As a base line, if we wanted to align
* the start of the container and the start of the item, we would return 0 in this function.
*/
fun Density.position(layoutSize: Int, itemSize: Int, itemIndex: Int): Int
companion object {
/**
* Aligns the center of the item with the center of the containing layout.
*/
val CenterToCenter =
SnapPositionInLayout { layoutSize, itemSize, _ -> layoutSize / 2 - itemSize / 2 }
}
}

View file

@ -4,8 +4,6 @@ import androidx.compose.animation.core.*
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.snapping.SnapFlingBehavior
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
@ -31,7 +29,7 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.common.Wallet
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
private const val SHORT_SNAP_ELEMENT_COUNT = 50
private const val SHORT_SNAP_ELEMENT_COUNT = 25
/**
* Wallets list component
@ -76,23 +74,20 @@ internal fun WalletsList(
/**
* Custom implementation of fling behaviour that overrides 'shortSnapVelocityThreshold'.
* Every user's drag action will similar to a short snap
* if drag offset is less than [SHORT_SNAP_ELEMENT_COUNT] * item width.
*
* @param lazyListState lazy list state
* @param itemWidth list item width
*
* @see rememberSnapFlingBehavior
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun rememberWalletsFlingBehaviour(lazyListState: LazyListState, itemWidth: Dp): SnapFlingBehavior {
val snappingLayout = remember(lazyListState) { SnapLayoutInfoProvider(lazyListState) }
private fun rememberWalletsFlingBehaviour(lazyListState: LazyListState, itemWidth: Dp): TangemSnapFlingBehavior {
val snappingLayout = remember(lazyListState) { TangemSnapLayoutInfoProvider(lazyListState) }
val density = LocalDensity.current
val highVelocityApproachSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay()
return remember(key1 = snappingLayout, key2 = highVelocityApproachSpec, key3 = density) {
SnapFlingBehavior(
TangemSnapFlingBehavior(
snapLayoutInfoProvider = snappingLayout,
lowVelocityAnimationSpec = tween(durationMillis = 1000, easing = LinearEasing),
highVelocityAnimationSpec = highVelocityApproachSpec,

View file

@ -1,6 +1,5 @@
package com.tangem.feature.wallet.presentation.wallet.ui.utils
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import kotlinx.coroutines.flow.FlowCollector
import kotlin.math.abs
@ -20,14 +19,14 @@ internal class ScrollOffsetCollector(
selectedWalletIndex: Int,
private val lazyListState: LazyListState,
private val onWalletChange: (Int) -> Unit,
) : FlowCollector<List<LazyListItemInfo>> {
) : FlowCollector<List<LazyListItemData>> {
private val LazyListItemInfo.halfItemSize
private val LazyListItemData.halfItemSize
get() = size.div(other = 2)
private var currentIndex = selectedWalletIndex
override suspend fun emit(value: List<LazyListItemInfo>) {
override suspend fun emit(value: List<LazyListItemData>) {
if (!lazyListState.isScrollInProgress || value.size <= 1) return
val firstItem = value.firstOrNull() ?: return
@ -46,4 +45,10 @@ internal class ScrollOffsetCollector(
onWalletChange(newIndex)
}
}
}
}
internal data class LazyListItemData(
val index: Int,
val size: Int,
val offset: Int,
)