Updated on 2026-08-14

This commit is contained in:
Tangem 2023-09-11 11:07:03 +03:00
commit f0d03e3648
286 changed files with 6353 additions and 5898 deletions

View file

@ -1,13 +1,17 @@
package com.tangem.feature.referral.converters
import android.text.format.DateUtils
import com.tangem.datasource.api.tangemTech.models.ReferralResponse
import com.tangem.feature.referral.domain.models.DiscountType
import com.tangem.feature.referral.domain.models.ReferralData
import com.tangem.feature.referral.domain.models.ReferralInfo
import com.tangem.feature.referral.domain.models.TokenData
import com.tangem.feature.referral.domain.models.*
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isToday
import com.tangem.utils.extensions.isYesterday
import com.tangem.utils.safeValueOf
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.joda.time.format.DateTimeFormatterBuilder
import org.joda.time.format.ISODateTimeFormat
import java.util.Locale
import javax.inject.Inject
class ReferralConverter @Inject constructor() : Converter<ReferralResponse, ReferralData> {
@ -16,6 +20,7 @@ class ReferralConverter @Inject constructor() : Converter<ReferralResponse, Refe
val conditions = value.conditions
val referral = value.referral
val tokenConverter = TokenConverter()
val expectedAwardsConverter = ExpectedAwardsConverter()
return if (referral != null) {
ReferralData.ParticipantData(
award = conditions.awards.firstOrNull()?.amount ?: 0,
@ -32,6 +37,9 @@ class ReferralConverter @Inject constructor() : Converter<ReferralResponse, Refe
ISODateTimeFormat.dateTimeParser().parseDateTime(referral.termsAcceptedAt)
},
),
expectedAwards = value.expectedAwards?.let {
expectedAwardsConverter.convert(it)
},
)
} else {
ReferralData.NonParticipantData(
@ -45,6 +53,47 @@ class ReferralConverter @Inject constructor() : Converter<ReferralResponse, Refe
}
}
private class ExpectedAwardsConverter : Converter<ReferralResponse.ExpectedAwards, ExpectedAwards> {
/** Example, 2 Aug, 2023 */
private val dateFormatter by lazy {
DateTimeFormatterBuilder()
.appendDayOfMonth(1)
.appendLiteral(' ')
.appendMonthOfYearShortText()
.appendLiteral(", ")
.appendYear(4, 4)
.toFormatter()
.withLocale(Locale.getDefault())
}
override fun convert(value: ReferralResponse.ExpectedAwards): ExpectedAwards {
return ExpectedAwards(
numberOfWallets = value.numberOfWallets,
expectedAwards = value.list.map {
ExpectedAward(
paymentDate = it.paymentDate.toDateTimeAtStartOfDay().millis.toDateFormat(),
amount = "${it.amount} ${it.currency}",
)
},
)
}
private fun Long.toDateFormat(): String {
val localDate = DateTime(this, DateTimeZone.getDefault())
return if (localDate.isToday() || localDate.isYesterday()) {
DateUtils.getRelativeTimeSpanString(
this,
DateTime.now().millis,
DateUtils.DAY_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
).toString()
} else {
dateFormatter.print(localDate)
}
}
}
private class TokenConverter : Converter<ReferralResponse.Conditions.Award.Token, TokenData> {
override fun convert(value: ReferralResponse.Conditions.Award.Token): TokenData {

View file

@ -22,7 +22,7 @@ internal class ReferralRepositoryImpl @Inject constructor(
override val isDemoMode: Boolean
get() = demoModeDatasource.isDemoModeActive
override suspend fun getReferralStatus(walletId: String): ReferralData {
override suspend fun getReferralData(walletId: String): ReferralData {
return withContext(coroutineDispatcher.io) {
referralConverter.convert(
referralApi.getReferralStatus(

View file

@ -20,9 +20,11 @@ internal class ReferralInteractorImpl(
get() = repository.isDemoMode
override suspend fun getReferralStatus(): ReferralData {
val refStatus = repository.getReferralStatus(userWalletManager.getWalletId())
saveRefTokens(refStatus.tokens)
return refStatus
val referralData = repository.getReferralData(userWalletManager.getWalletId())
saveReferralTokens(referralData.tokens)
return referralData
}
override suspend fun startReferral(): ReferralData {
@ -37,7 +39,7 @@ internal class ReferralInteractorImpl(
address = publicAddress,
)
} else {
error("tokens for ref is empty")
error("Tokens for ref is empty")
}
}
@ -53,7 +55,7 @@ internal class ReferralInteractorImpl(
return derivationPath
}
private fun saveRefTokens(tokens: List<TokenData>) {
private fun saveReferralTokens(tokens: List<TokenData>) {
tokensForReferral.clear()
tokensForReferral.addAll(tokens)
}

View file

@ -7,7 +7,7 @@ interface ReferralRepository {
val isDemoMode: Boolean
/** Returns data object of [ReferralData] depends on user program status */
suspend fun getReferralStatus(walletId: String): ReferralData
suspend fun getReferralData(walletId: String): ReferralData
/** Starts user referral program */
suspend fun startReferral(walletId: String, networkId: String, tokenId: String, address: String): ReferralData

View file

@ -18,6 +18,7 @@ sealed interface ReferralData {
override val tosLink: String,
override val tokens: List<TokenData>,
val referral: ReferralInfo,
val expectedAwards: ExpectedAwards?,
) : ReferralData
/** Data class that used if user is not participant of program */
@ -47,6 +48,16 @@ data class ReferralInfo(
val termsAcceptedAt: DateTime?,
)
data class ExpectedAwards(
val numberOfWallets: Int,
val expectedAwards: List<ExpectedAward>,
)
data class ExpectedAward(
val paymentDate: String,
val amount: String,
)
enum class DiscountType {
PERCENTAGE, VALUE
}

View file

@ -1,5 +1,7 @@
package com.tangem.feature.referral.models
import com.tangem.feature.referral.domain.models.ExpectedAwards
internal data class ReferralStateHolder(
val headerState: HeaderState,
val referralInfoState: ReferralInfoState,
@ -26,6 +28,7 @@ internal data class ReferralStateHolder(
val code: String,
val shareLink: String,
override val url: String,
val expectedAwards: ExpectedAwards?,
) : ReferralInfoState, ReferralInfoContentState
data class NonParticipantContent(

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -43,13 +44,16 @@ internal fun AgreementBottomSheetContent(url: String) {
@Composable
private fun AgreementHtmlView(url: String) {
val state = rememberWebViewState(url)
val isInPreviewMode = LocalInspectionMode.current
WebView(
state = state,
modifier = Modifier.background(TangemTheme.colors.background.secondary),
onCreated = {
it.settings.apply {
javaScriptEnabled = false
allowFileAccess = false
if (!isInPreviewMode) {
it.settings.apply {
javaScriptEnabled = false
allowFileAccess = false
}
}
},
)

View file

@ -0,0 +1,71 @@
package com.tangem.feature.referral.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.res.TangemTheme
@Suppress("LongParameterList")
@Composable
internal fun AwardText(
startText: String,
startTextColor: Color,
startTextStyle: TextStyle,
endText: String,
endTextColor: Color,
endTextStyle: TextStyle,
cornersToRound: CornersToRound,
) {
Surface(
shape = cornersToRound.getShape(),
color = TangemTheme.colors.background.primary,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(TangemTheme.dimens.size48)
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = startText,
color = startTextColor,
maxLines = 1,
style = startTextStyle,
)
Text(
text = endText,
color = endTextColor,
maxLines = 1,
style = endTextStyle,
)
}
}
}
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun Preview_AwardItem_Light() {
TangemTheme {
AwardText(
startText = "startText",
startTextColor = TangemTheme.colors.text.tertiary,
startTextStyle = TangemTheme.typography.subtitle2,
endText = "endText",
endTextColor = TangemTheme.colors.text.primary1,
endTextStyle = TangemTheme.typography.subtitle2,
cornersToRound = CornersToRound.TOP_2,
)
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.feature.referral.ui
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
internal enum class CornersToRound {
ALL_4,
TOP_2,
BOTTOM_2,
ZERO,
;
@Suppress("TopLevelComposableFunctions")
@Composable
fun getShape(): RoundedCornerShape {
val radius = TangemTheme.dimens.radius12
return when (this) {
ALL_4 -> RoundedCornerShape(radius)
TOP_2 -> RoundedCornerShape(topStart = radius, topEnd = radius)
BOTTOM_2 -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
ZERO -> RoundedCornerShape(0.dp)
}
}
}

View file

@ -2,15 +2,19 @@ package com.tangem.feature.referral.ui
import android.content.Context
import android.content.Intent
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@ -19,14 +23,16 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.content.ContextCompat.startActivity
import com.tangem.core.ui.components.PrimaryStartIconButton
import com.tangem.core.ui.components.SmallInfoCard
import com.tangem.core.ui.components.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.referral.domain.models.ExpectedAward
import com.tangem.feature.referral.domain.models.ExpectedAwards
import com.tangem.feature.referral.presentation.R
@Suppress("LongParameterList")
@ -36,6 +42,7 @@ internal fun ParticipateBottomBlock(
purchasedWalletCount: Int,
code: String,
shareLink: String,
expectedAwards: ExpectedAwards?,
onAgreementClick: () -> Unit,
onShowCopySnackbar: () -> Unit,
onCopyClick: () -> Unit,
@ -50,14 +57,6 @@ internal fun ParticipateBottomBlock(
.padding(horizontal = TangemTheme.dimens.spacing16),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
SmallInfoCard(
startText = stringResource(id = R.string.referral_friends_bought_title),
endText = pluralStringResource(
id = R.plurals.referral_wallets_purchased_count,
count = purchasedWalletCount,
purchasedWalletCount,
),
)
PersonalCodeCard(code = code)
AdditionalButtons(
code = code,
@ -66,10 +65,187 @@ internal fun ParticipateBottomBlock(
onCopyClick = onCopyClick,
onShareClick = onShareClick,
)
CounterAndAwards(purchasedWalletCount = purchasedWalletCount, expectedAwards = expectedAwards)
AgreementText(firstPartResId = R.string.referral_tos_enroled_prefix, onClick = onAgreementClick)
}
}
@Composable
private fun CounterAndAwards(purchasedWalletCount: Int, expectedAwards: ExpectedAwards?) {
Column {
Counter(purchasedWalletCount, expectedAwards)
if (expectedAwards != null) {
Awards(expectedAwards)
} else if (purchasedWalletCount != 0) {
EmptyUpcomingPayments()
}
}
}
@Composable
private fun Counter(purchasedWalletCount: Int, expectedAwards: ExpectedAwards?) {
val isExpectedAwardsPresent = expectedAwards != null
AwardText(
startText = stringResource(id = R.string.referral_friends_bought_title),
startTextColor = TangemTheme.colors.text.tertiary,
startTextStyle = TangemTheme.typography.subtitle2,
endText = pluralStringResource(
id = R.plurals.referral_wallets_purchased_count,
count = purchasedWalletCount,
purchasedWalletCount,
),
endTextColor = TangemTheme.colors.text.primary1,
endTextStyle = TangemTheme.typography.body2,
cornersToRound = if (isExpectedAwardsPresent || purchasedWalletCount != 0) {
CornersToRound.TOP_2
} else {
CornersToRound.ALL_4
},
)
}
@Suppress("MagicNumber")
@Composable
private fun Awards(expectedAwards: ExpectedAwards) {
val elementsCountToShowInLessMode = 3
val isExpanded = remember { mutableStateOf(false) }
Divider(
color = TangemTheme.colors.stroke.primary,
thickness = TangemTheme.dimens.size0_5,
)
AwardText(
startText = stringResource(id = R.string.referral_expected_awards),
startTextColor = TangemTheme.colors.text.tertiary,
startTextStyle = TangemTheme.typography.subtitle2,
endText = pluralStringResource(
id = R.plurals.referral_number_of_wallets,
count = expectedAwards.numberOfWallets,
expectedAwards.numberOfWallets,
),
endTextColor = TangemTheme.colors.text.tertiary,
endTextStyle = TangemTheme.typography.body2,
cornersToRound = CornersToRound.ZERO,
)
val initialItems = expectedAwards.expectedAwards.take(elementsCountToShowInLessMode)
val extraItems = expectedAwards.expectedAwards.drop(elementsCountToShowInLessMode)
initialItems.forEachIndexed { index, expectedAward ->
AwardText(
startText = expectedAward.paymentDate,
startTextColor = TangemTheme.colors.text.primary1,
startTextStyle = TangemTheme.typography.subtitle2,
endText = expectedAward.amount,
endTextColor = TangemTheme.colors.text.primary1,
endTextStyle = TangemTheme.typography.subtitle2,
cornersToRound = if (index == initialItems.size - 1 && extraItems.isEmpty()) {
CornersToRound.BOTTOM_2
} else {
CornersToRound.ZERO
},
)
}
AnimatedVisibility(
visible = isExpanded.value,
enter = fadeIn() + expandVertically(),
exit = shrinkVertically() + fadeOut(),
) {
ExtraItems(extraItems = extraItems)
}
if (expectedAwards.expectedAwards.size > elementsCountToShowInLessMode) {
LessMoreButton(isExpanded = isExpanded)
}
}
@Composable
private fun EmptyUpcomingPayments() {
Divider(
color = TangemTheme.colors.stroke.primary,
thickness = TangemTheme.dimens.size0_5,
)
AwardText(
startText = stringResource(id = R.string.referral_expected_awards),
startTextColor = TangemTheme.colors.text.tertiary,
startTextStyle = TangemTheme.typography.subtitle2,
endText = "",
endTextColor = TangemTheme.colors.text.tertiary,
endTextStyle = TangemTheme.typography.body2,
cornersToRound = CornersToRound.BOTTOM_2,
)
}
@Composable
private fun LessMoreButton(isExpanded: MutableState<Boolean>) {
Surface(
shape = RoundedCornerShape(
bottomStart = TangemTheme.dimens.radius12,
bottomEnd = TangemTheme.dimens.radius12,
),
) {
Column(
modifier = Modifier.background(TangemTheme.colors.background.primary),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(TangemTheme.dimens.size48)
.clickable { isExpanded.value = !isExpanded.value }
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (isExpanded.value) {
stringResource(id = R.string.referral_less)
} else {
stringResource(id = R.string.referral_more)
},
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
)
val chevronIcon = if (isExpanded.value) {
painterResource(id = com.tangem.core.ui.R.drawable.ic_chevron_up_24)
} else {
painterResource(id = com.tangem.core.ui.R.drawable.ic_chevron_24)
}
Icon(
modifier = Modifier.size(TangemTheme.dimens.size20),
painter = chevronIcon,
tint = TangemTheme.colors.text.tertiary,
contentDescription = null,
)
}
}
}
}
@Composable
private fun ExtraItems(extraItems: List<ExpectedAward>) {
Column {
extraItems.forEach { expectedAward ->
AwardText(
startText = expectedAward.paymentDate,
startTextColor = TangemTheme.colors.text.primary1,
startTextStyle = TangemTheme.typography.subtitle2,
endText = expectedAward.amount,
endTextColor = TangemTheme.colors.text.primary1,
endTextStyle = TangemTheme.typography.subtitle2,
cornersToRound = CornersToRound.ZERO,
)
}
}
}
@Composable
private fun PersonalCodeCard(code: String) {
Column(
@ -157,11 +333,28 @@ private fun Context.shareText(text: String) {
@Composable
private fun Preview_ParticipateBottomBlock_InLightTheme() {
TangemTheme(isDark = false) {
Column(Modifier.background(TangemTheme.colors.background.primary)) {
Column(Modifier.background(TangemTheme.colors.background.secondary)) {
ParticipateBottomBlock(
purchasedWalletCount = 3,
code = "x4JdK",
shareLink = "",
expectedAwards = ExpectedAwards(
numberOfWallets = 3,
expectedAwards = listOf(
ExpectedAward(
amount = "10 USDT",
paymentDate = "Today",
),
ExpectedAward(
amount = "20 USDT",
paymentDate = "6 Aug 2023",
),
ExpectedAward(
amount = "30 USDT",
paymentDate = "10 Aug 2023",
),
),
),
onAgreementClick = {},
onShowCopySnackbar = {},
onCopyClick = {},
@ -173,13 +366,64 @@ private fun Preview_ParticipateBottomBlock_InLightTheme() {
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun Preview_ParticipateBottomBlock_InDarkTheme() {
TangemTheme(isDark = true) {
Column(Modifier.background(TangemTheme.colors.background.primary)) {
private fun Preview_ParticipateBottomBlock_Without_Awards_InLightTheme() {
TangemTheme(isDark = false) {
Column(Modifier.background(TangemTheme.colors.background.secondary)) {
ParticipateBottomBlock(
purchasedWalletCount = 3,
code = "x4JdK",
shareLink = "",
expectedAwards = null,
onAgreementClick = {},
onShowCopySnackbar = {},
onCopyClick = {},
onShareClick = {},
)
}
}
}
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun Preview_ParticipateBottomBlock_Without_Awards_And_Purchased_Wallets_InLightTheme() {
TangemTheme(isDark = false) {
Column(Modifier.background(TangemTheme.colors.background.secondary)) {
ParticipateBottomBlock(
purchasedWalletCount = 0,
code = "x4JdK",
shareLink = "",
expectedAwards = null,
onAgreementClick = {},
onShowCopySnackbar = {},
onCopyClick = {},
onShareClick = {},
)
}
}
}
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun LessMoreButton_White() {
TangemTheme(isDark = false) {
LessMoreButton(
isExpanded = remember {
mutableStateOf(false)
},
)
}
}
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun Preview_ParticipateBottomBlock_Without_Awards_InDarkTheme() {
TangemTheme(isDark = true) {
Column(Modifier.background(TangemTheme.colors.background.secondary)) {
ParticipateBottomBlock(
purchasedWalletCount = 3,
code = "x4JdK",
shareLink = "",
expectedAwards = null,
onAgreementClick = {},
onShowCopySnackbar = {},
onCopyClick = {},

View file

@ -19,6 +19,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
@ -31,6 +32,8 @@ import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.referral.domain.models.ExpectedAward
import com.tangem.feature.referral.domain.models.ExpectedAwards
import com.tangem.feature.referral.models.DemoModeException
import com.tangem.feature.referral.models.ReferralStateHolder
import com.tangem.feature.referral.models.ReferralStateHolder.*
@ -159,6 +162,7 @@ private fun ReferralInfo(
purchasedWalletCount = state.purchasedWalletCount,
code = state.code,
shareLink = state.shareLink,
expectedAwards = state.expectedAwards,
onAgreementClick = onAgreementClick,
onShowCopySnackbar = onShowCopySnackbar,
onCopyClick = stateHolder.analytics.onCopyClicked,
@ -253,25 +257,47 @@ private fun Condition(@DrawableRes iconResId: Int, infoBlock: @Composable () ->
private fun InfoForYou(award: String, networkName: String, address: String? = null) {
ConditionInfo(title = stringResource(id = R.string.referral_point_currencies_title)) {
Text(
text = buildAnnotatedString {
append(stringResource(id = R.string.referral_point_currencies_description_prefix))
withStyle(SpanStyle(color = TangemTheme.colors.text.primary1)) {
append(" $award ")
}
append(
String.format(
stringResource(id = R.string.referral_point_currencies_description_suffix),
networkName,
if (!address.isNullOrBlank()) " $address" else "",
),
)
},
formatAwardConditionsString(
quantity = award,
network = networkName,
address = if (!address.isNullOrBlank()) " $address" else "",
),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
)
}
}
@Composable
private fun formatAwardConditionsString(quantity: String, network: String, address: String): AnnotatedString {
val rawString = stringResource(R.string.referral_point_currencies_description, quantity, network, address)
val pattern = Regex("\\^\\^(.*?)\\^\\^")
var startIndex = 0
val annotatedString = buildAnnotatedString {
pattern.findAll(rawString).forEach { matchResult ->
val index = matchResult.range.first
val matchedValue = matchResult.groups[1]?.value ?: ""
// appends unformatted part
append(rawString.substring(startIndex, index))
// applies style on ^^-wrapped parts
withStyle(SpanStyle(color = TangemTheme.colors.text.primary1)) {
append(matchedValue)
}
// goes to next part
startIndex = matchResult.range.last + 1
}
// appends remaining ending if exists
append(rawString.substring(startIndex))
}
return annotatedString
}
@Composable
private fun InfoForYourFriend(discount: String) {
ConditionInfo(title = stringResource(id = R.string.referral_point_discount_title)) {
@ -464,6 +490,7 @@ private fun Preview_ReferralScreen_Participant_InLightTheme() {
code = "x4JdK",
shareLink = "",
url = "",
expectedAwards = null,
),
errorSnackbar = null,
analytics = Analytics(
@ -492,6 +519,52 @@ private fun Preview_ReferralScreen_Participant_InDarkTheme() {
code = "x4JdK",
shareLink = "",
url = "",
expectedAwards = null,
),
errorSnackbar = null,
analytics = Analytics(
onAgreementClicked = {},
onCopyClicked = {},
onShareClicked = {},
),
),
)
}
}
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun Preview_ReferralScreen_Participant_With_Referrals_InLightTheme() {
TangemTheme(isDark = false) {
ReferralScreen(
stateHolder = ReferralStateHolder(
headerState = HeaderState(onBackClicked = {}),
referralInfoState = ReferralInfoState.ParticipantContent(
award = "10 USDT",
networkName = "Tron",
address = "ma80...zk8q2",
discount = "10%",
purchasedWalletCount = 3,
code = "x4JdK",
shareLink = "",
url = "",
expectedAwards = ExpectedAwards(
numberOfWallets = 5,
expectedAwards = listOf(
ExpectedAward(
amount = "10 USDT",
paymentDate = "Today",
),
ExpectedAward(
amount = "20 USDT",
paymentDate = "6 Aug 2023",
),
ExpectedAward(
amount = "30 USDT",
paymentDate = "10 Aug 2023",
),
),
),
),
errorSnackbar = null,
analytics = Analytics(

View file

@ -36,7 +36,7 @@ internal class ReferralViewModel @Inject constructor(
private var referralRouter: ReferralRouter by Delegates.notNull()
private val lastReferralData = mutableStateOf<ReferralData?>(null)
private var lastReferralData: ReferralData? = null
init {
loadReferralData()
@ -67,7 +67,7 @@ internal class ReferralViewModel @Inject constructor(
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
referralInteractor.getReferralStatus().apply {
lastReferralData.value = this
lastReferralData = this
}
}
.onSuccess(::showContent)
@ -84,14 +84,13 @@ internal class ReferralViewModel @Inject constructor(
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) { referralInteractor.startReferral() }
.onSuccess(::showContent)
.onFailure {
if (it is UserCancelledException) {
val lastRefData = lastReferralData.value
if (lastRefData != null) {
showContent(lastRefData)
.onFailure { throwable ->
if (throwable is UserCancelledException) {
lastReferralData?.let { referralData ->
showContent(referralData)
}
} else {
showErrorSnackbar(it)
showErrorSnackbar(throwable)
}
}
}
@ -130,6 +129,7 @@ internal class ReferralViewModel @Inject constructor(
code = referral.promocode,
shareLink = referral.shareLink,
url = tosLink,
expectedAwards = expectedAwards,
)
is ReferralData.NonParticipantData -> ReferralInfoState.NonParticipantContent(
award = getAwardValue(),

View file

@ -27,6 +27,7 @@ dependencies {
implementation(deps.compose.paging)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.ui.utils)
implementation(deps.arrow.core)
implementation(deps.jodatime)

View file

@ -2,6 +2,9 @@ package com.tangem.feature.tokendetails.presentation.tokendetails
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsAppBarMenuConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarConfig
@ -13,7 +16,18 @@ import kotlinx.coroutines.flow.MutableStateFlow
internal object TokenDetailsPreviewData {
val tokenDetailsTopAppBarConfig = TokenDetailsTopAppBarConfig(onBackClick = {}, onMoreClick = {})
val tokenDetailsTopAppBarConfig = TokenDetailsTopAppBarConfig(
onBackClick = {},
tokenDetailsAppBarMenuConfig = TokenDetailsAppBarMenuConfig(
persistentListOf(
TokenDetailsAppBarMenuConfig.MenuItem(
title = TextReference.Res(id = R.string.token_details_hide_token),
textColorProvider = { TangemTheme.colors.text.warning },
onClick = { },
),
),
),
)
val tokenInfoBlockStateWithLongNameInMainCurrency = TokenInfoBlockState(
name = "Stellar (XLM) with long name test",
@ -67,5 +81,7 @@ internal object TokenDetailsPreviewData {
value = TxHistoryState.getDefaultLoadingTransactions {},
),
),
dialogConfig = null,
pendingTxs = persistentListOf(),
)
}

View file

@ -0,0 +1,14 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
internal data class TokenDetailsAppBarMenuConfig(val items: ImmutableList<MenuItem>) {
data class MenuItem(
val title: TextReference,
val textColorProvider: @Composable () -> Color,
val onClick: () -> Unit,
)
}

View file

@ -1,12 +1,17 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig
import kotlinx.collections.immutable.PersistentList
internal data class TokenDetailsState(
val topAppBarConfig: TokenDetailsTopAppBarConfig,
val tokenInfoBlockState: TokenInfoBlockState,
val tokenBalanceBlockState: TokenDetailsBalanceBlockState,
val marketPriceBlockState: MarketPriceBlockState,
val pendingTxs: PersistentList<TransactionState>,
val txHistoryState: TxHistoryState,
val dialogConfig: TokenDetailsDialogConfig?,
)

View file

@ -1,6 +1,6 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state
data class TokenDetailsTopAppBarConfig(
internal data class TokenDetailsTopAppBarConfig(
val onBackClick: () -> Unit,
val onMoreClick: () -> Unit,
val tokenDetailsAppBarMenuConfig: TokenDetailsAppBarMenuConfig,
)

View file

@ -2,7 +2,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state
import androidx.annotation.DrawableRes
data class TokenInfoBlockState(
internal data class TokenInfoBlockState(
val name: String,
val iconUrl: String,
val currency: Currency,

View file

@ -0,0 +1,81 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.components
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.tokendetails.impl.R
/**
* Wallet bottom sheet config
*
* @property isShow flag that determine if bottom sheet is shown
* @property onDismissRequest lambda be invoked when bottom sheet is dismissed
* @property content content config
*/
internal data class TokenDetailsDialogConfig(
val isShow: Boolean,
val onDismissRequest: () -> Unit,
val content: DialogContentConfig,
) {
sealed class DialogContentConfig {
abstract val title: TextReference
abstract val message: TextReference
abstract val confirmButtonConfig: ButtonConfig
abstract val cancelButtonConfig: ButtonConfig?
data class ButtonConfig(
val text: TextReference,
val onClick: () -> Unit,
val warning: Boolean = false,
)
data class ConfirmHideConfig(
val currencySymbol: String,
val onConfirmClick: () -> Unit,
val onCancelClick: () -> Unit,
) : DialogContentConfig() {
override val title: TextReference = TextReference.Res(
id = R.string.token_details_hide_alert_title,
formatArgs = wrappedList(currencySymbol),
)
override val message: TextReference = TextReference.Res(R.string.token_details_hide_alert_message)
override val cancelButtonConfig: ButtonConfig = ButtonConfig(
text = TextReference.Res(R.string.common_cancel),
onClick = onCancelClick,
)
override val confirmButtonConfig: ButtonConfig = ButtonConfig(
text = TextReference.Res(R.string.token_details_hide_alert_hide),
onClick = onConfirmClick,
warning = true,
)
}
data class HasLinkedTokensConfig(
val currencySymbol: String,
val networkName: String,
val onConfirmClick: () -> Unit,
) : DialogContentConfig() {
override val title: TextReference = TextReference.Res(
id = R.string.token_details_unable_hide_alert_title,
formatArgs = wrappedList(currencySymbol),
)
override val message: TextReference = TextReference.Res(
id = R.string.token_details_unable_hide_alert_message,
formatArgs = wrappedList(currencySymbol, networkName),
)
override val cancelButtonConfig: ButtonConfig?
get() = null
override val confirmButtonConfig: ButtonConfig = ButtonConfig(
text = TextReference.Res(R.string.common_ok),
onClick = onConfirmClick,
)
}
}
}

View file

@ -10,14 +10,22 @@ import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsTxHistoryToTransactionStateConverter
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
internal class TokenDetailsLoadedBalanceConverter(
private val currentStateProvider: Provider<TokenDetailsState>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val symbol: String,
private val decimals: Int,
) : Converter<Either<CurrencyStatusError, CryptoCurrencyStatus>, TokenDetailsState> {
private val txHistoryItemConverter by lazy {
TokenDetailsTxHistoryToTransactionStateConverter(symbol, decimals)
}
override fun convert(value: Either<CurrencyStatusError, CryptoCurrencyStatus>): TokenDetailsState {
return value.fold(ifLeft = { convertError() }, ifRight = ::convert)
}
@ -33,6 +41,7 @@ internal class TokenDetailsLoadedBalanceConverter(
return state.copy(
tokenBalanceBlockState = getBalanceState(state.tokenBalanceBlockState, status),
marketPriceBlockState = getMarketPriceState(status = status.value, currencyName = currencyName),
pendingTxs = status.value.pendingTransactions.map(txHistoryItemConverter::convert).toPersistentList(),
)
}

View file

@ -2,15 +2,15 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.extensions.iconResId
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.tokens.models.CryptoCurrency
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenInfoBlockState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.*
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsActionButton
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSkeletonStateConverter.SkeletonModel
import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -24,7 +24,7 @@ internal class TokenDetailsSkeletonStateConverter(
return TokenDetailsState(
topAppBarConfig = TokenDetailsTopAppBarConfig(
onBackClick = clickIntents::onBackClick,
onMoreClick = clickIntents::onMoreClick,
tokenDetailsAppBarMenuConfig = createMenu(),
),
tokenInfoBlockState = TokenInfoBlockState(
name = value.cryptoCurrency.name,
@ -34,7 +34,7 @@ internal class TokenDetailsSkeletonStateConverter(
is CryptoCurrency.Token -> TokenInfoBlockState.Currency.Token(
networkName = currency.network.standardType.name,
blockchainName = currency.network.name,
networkIcon = currency.iconResId,
networkIcon = currency.networkIconResId,
)
},
),
@ -42,14 +42,26 @@ internal class TokenDetailsSkeletonStateConverter(
actionButtons = createButtons(),
),
marketPriceBlockState = MarketPriceBlockState.Loading(value.cryptoCurrency.name),
pendingTxs = persistentListOf(),
txHistoryState = TxHistoryState.Content(
contentItems = MutableStateFlow(
value = TxHistoryState.getDefaultLoadingTransactions(clickIntents::onExploreClick),
),
),
dialogConfig = null,
)
}
private fun createMenu(): TokenDetailsAppBarMenuConfig = TokenDetailsAppBarMenuConfig(
items = persistentListOf(
TokenDetailsAppBarMenuConfig.MenuItem(
title = TextReference.Res(id = R.string.token_details_hide_token),
textColorProvider = { TangemTheme.colors.text.warning },
onClick = clickIntents::onHideClick,
),
),
)
private fun createButtons(): ImmutableList<TokenDetailsActionButton> {
return persistentListOf(
TokenDetailsActionButton.Buy(enabled = false, onClick = {}),

View file

@ -12,6 +12,7 @@ import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.domain.txhistory.models.TxHistoryListError
import com.tangem.domain.txhistory.models.TxHistoryStateError
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadedTxHistoryConverter
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadingTxHistoryConverter
import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents
@ -33,6 +34,8 @@ internal class TokenDetailsStateFactory(
TokenDetailsLoadedBalanceConverter(
currentStateProvider = currentStateProvider,
appCurrencyProvider = appCurrencyProvider,
symbol = symbol,
decimals = decimals,
)
}
@ -81,4 +84,37 @@ internal class TokenDetailsStateFactory(
): TokenDetailsState {
return loadedTxHistoryConverter.convert(txHistoryEither)
}
fun getStateWithClosedDialog(): TokenDetailsState {
val state = currentStateProvider()
return state.copy(dialogConfig = state.dialogConfig?.copy(isShow = false))
}
fun getStateWithConfirmHideTokenDialog(currency: CryptoCurrency): TokenDetailsState {
return currentStateProvider().copy(
dialogConfig = TokenDetailsDialogConfig(
isShow = true,
onDismissRequest = clickIntents::onDismissDialog,
content = TokenDetailsDialogConfig.DialogContentConfig.ConfirmHideConfig(
currencySymbol = currency.symbol,
onConfirmClick = clickIntents::onHideConfirmed,
onCancelClick = clickIntents::onDismissDialog,
),
),
)
}
fun getStateWithLinkedTokensDialog(currency: CryptoCurrency): TokenDetailsState {
return currentStateProvider().copy(
dialogConfig = TokenDetailsDialogConfig(
isShow = true,
onDismissRequest = clickIntents::onDismissDialog,
content = TokenDetailsDialogConfig.DialogContentConfig.HasLinkedTokensConfig(
currencySymbol = currency.symbol,
networkName = currency.network.name,
onConfirmClick = clickIntents::onDismissDialog,
),
),
)
}
}

View file

@ -0,0 +1,94 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory
import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.converter.Converter
import com.tangem.utils.toBriefAddressFormat
import com.tangem.utils.toFormattedCurrencyString
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.joda.time.format.DateTimeFormatterBuilder
import java.math.BigDecimal
import java.util.Locale
internal class TokenDetailsTxHistoryToTransactionStateConverter(
private val symbol: String,
private val decimals: Int,
) : Converter<TxHistoryItem, TransactionState> {
/** Example, 13:35 */
private val timeFormatter by lazy {
DateTimeFormatterBuilder()
.appendHourOfDay(1)
.appendLiteral(':')
.appendMinuteOfHour(2)
.toFormatter()
.withLocale(Locale.getDefault())
}
override fun convert(value: TxHistoryItem): TransactionState {
return when (value.type) {
TxHistoryItem.TransactionType.Transfer -> {
when (val direction = value.direction) {
is TxHistoryItem.TransactionDirection.Incoming -> {
createIncomingTransferTransaction(value, direction)
}
is TxHistoryItem.TransactionDirection.Outgoing -> {
createOutgoingTransferTransaction(value, direction)
}
}
}
}
}
private fun createIncomingTransferTransaction(
item: TxHistoryItem,
direction: TxHistoryItem.TransactionDirection.Incoming,
): TransactionState {
return when (item.status) {
TxHistoryItem.TxStatus.Confirmed -> TransactionState.Receive(
txHash = item.txHash,
address = direction.extractAddress(),
amount = item.amount.toCryptoCurrencyFormat(),
timestamp = timeFormatter.print(DateTime(item.timestampInMillis, DateTimeZone.getDefault())),
)
TxHistoryItem.TxStatus.Unconfirmed -> TransactionState.Receiving(
txHash = item.txHash,
address = direction.extractAddress(),
amount = item.amount.toCryptoCurrencyFormat(),
timestamp = timeFormatter.print(DateTime(item.timestampInMillis, DateTimeZone.getDefault())),
)
}
}
private fun createOutgoingTransferTransaction(
item: TxHistoryItem,
direction: TxHistoryItem.TransactionDirection.Outgoing,
): TransactionState {
return when (item.status) {
TxHistoryItem.TxStatus.Confirmed -> TransactionState.Send(
txHash = item.txHash,
address = direction.extractAddress(),
amount = item.amount.toCryptoCurrencyFormat(),
timestamp = timeFormatter.print(DateTime(item.timestampInMillis, DateTimeZone.getDefault())),
)
TxHistoryItem.TxStatus.Unconfirmed -> TransactionState.Sending(
txHash = item.txHash,
address = direction.extractAddress(),
amount = item.amount.toCryptoCurrencyFormat(),
timestamp = timeFormatter.print(DateTime(item.timestampInMillis, DateTimeZone.getDefault())),
)
}
}
private fun BigDecimal.toCryptoCurrencyFormat(): String {
return toFormattedCurrencyString(currency = symbol, decimals = decimals)
}
private fun TxHistoryItem.TransactionDirection.extractAddress(): TextReference = when (val addr = address) {
TxHistoryItem.Address.Multiple -> TextReference.Res(R.string.transaction_history_multiple_addresses)
is TxHistoryItem.Address.Single -> TextReference.Str(addr.rawAddress.toBriefAddressFormat())
}
}

View file

@ -1,23 +1,33 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Scaffold
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.tooling.preview.Preview
import androidx.compose.ui.util.fastForEach
import androidx.paging.compose.collectAsLazyPagingItems
import com.tangem.core.ui.components.marketprice.MarketPriceBlock
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.transactions.Transaction
import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.components.transactions.txHistoryItems
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPreviewData
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsDialogs
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsTopAppBar
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenInfoBlock
import kotlinx.collections.immutable.PersistentList
@Composable
internal fun TokenDetailsScreen(state: TokenDetailsState) {
@ -54,8 +64,31 @@ internal fun TokenDetailsScreen(state: TokenDetailsState) {
contentType = MarketPriceBlockState::class.java,
content = { MarketPriceBlock(modifier = itemModifier, state = state.marketPriceBlockState) },
)
if (state.txHistoryState is TxHistoryState.NotSupported && state.pendingTxs.isNotEmpty()) {
item {
PendingTxsBlock(
pendingTxs = state.pendingTxs,
modifier = itemModifier,
)
}
}
txHistoryItems(state = state.txHistoryState, txHistoryItems = txHistoryItems)
}
TokenDetailsDialogs(state = state)
}
}
@Composable
private fun PendingTxsBlock(pendingTxs: PersistentList<TransactionState>, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
.background(color = TangemTheme.colors.background.primary),
verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8),
horizontalAlignment = Alignment.Start,
) {
pendingTxs.fastForEach { Transaction(state = it) }
}
}

View file

@ -0,0 +1,229 @@
@file:Suppress("TopLevelPropertyNaming")
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
/**
* Just copy paste [DropdownMenu] from material3 with deleting vertical paddings.
*/
@Composable
internal fun TangemDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp),
properties: PopupProperties = PopupProperties(focusable = true),
content: @Composable ColumnScope.() -> Unit,
) {
val expandedStates = remember { MutableTransitionState(false) }
expandedStates.targetState = expanded
if (expandedStates.currentState || expandedStates.targetState) {
val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) }
val density = LocalDensity.current
val popupPositionProvider = DropdownMenuPositionProvider(
offset,
density,
) { parentBounds, menuBounds ->
transformOriginState.value = calculateTransformOrigin(parentBounds, menuBounds)
}
Popup(
onDismissRequest = onDismissRequest,
popupPositionProvider = popupPositionProvider,
properties = properties,
) {
DropdownMenuContent(
expandedStates = expandedStates,
transformOriginState = transformOriginState,
modifier = modifier,
content = content,
)
}
}
}
private const val InTransitionDuration = 120
private const val OutTransitionDuration = 75
@Suppress("ReusedModifierInstance", "MagicNumber")
@Composable
private fun DropdownMenuContent(
expandedStates: MutableTransitionState<Boolean>,
transformOriginState: MutableState<TransformOrigin>,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
// Menu open/close animation.
val transition = updateTransition(expandedStates, "DropDownMenu")
val scale by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(
durationMillis = InTransitionDuration,
easing = LinearOutSlowInEasing,
)
} else {
// Expanded to dismissed.
tween(
durationMillis = 1,
delayMillis = OutTransitionDuration - 1,
)
}
},
label = "",
) {
if (it) {
// Menu is expanded.
1f
} else {
// Menu is dismissed.
0.8f
}
}
val alpha by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(durationMillis = 30)
} else {
// Expanded to dismissed.
tween(durationMillis = OutTransitionDuration)
}
},
label = "",
) {
if (it) {
// Menu is expanded.
1f
} else {
// Menu is dismissed.
0f
}
}
Card(
modifier = Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
transformOrigin = transformOriginState.value
},
elevation = CardDefaults.cardElevation(),
) {
Column(
modifier = modifier
.width(IntrinsicSize.Max)
.verticalScroll(rememberScrollState()),
content = content,
)
}
}
private fun calculateTransformOrigin(parentBounds: IntRect, menuBounds: IntRect): TransformOrigin {
val pivotX = when {
menuBounds.left >= parentBounds.right -> 0f
menuBounds.right <= parentBounds.left -> 1f
menuBounds.width == 0 -> 0f
else -> {
val intersectionCenter =
(
kotlin.math.max(parentBounds.left, menuBounds.left) +
kotlin.math.min(parentBounds.right, menuBounds.right)
) / 2
(intersectionCenter - menuBounds.left).toFloat() / menuBounds.width
}
}
val pivotY = when {
menuBounds.top >= parentBounds.bottom -> 0f
menuBounds.bottom <= parentBounds.top -> 1f
menuBounds.height == 0 -> 0f
else -> {
val intersectionCenter =
(
kotlin.math.max(parentBounds.top, menuBounds.top) +
kotlin.math.min(parentBounds.bottom, menuBounds.bottom)
) / 2
(intersectionCenter - menuBounds.top).toFloat() / menuBounds.height
}
}
return TransformOrigin(pivotX, pivotY)
}
private val MenuVerticalMargin = 48.dp
@Immutable
internal data class DropdownMenuPositionProvider(
val contentOffset: DpOffset,
val density: Density,
val onPositionCalculated: (IntRect, IntRect) -> Unit = { _, _ -> },
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
// The min margin above and below the menu, relative to the screen.
val verticalMargin = with(density) { MenuVerticalMargin.roundToPx() }
// The content offset specified using the dropdown offset parameter.
val contentOffsetX = with(density) { contentOffset.x.roundToPx() }
val contentOffsetY = with(density) { contentOffset.y.roundToPx() }
// Compute horizontal position.
val toRight = anchorBounds.left + contentOffsetX
val toLeft = anchorBounds.right - contentOffsetX - popupContentSize.width
val toDisplayRight = windowSize.width - popupContentSize.width
val toDisplayLeft = 0
val x = if (layoutDirection == LayoutDirection.Ltr) {
sequenceOf(
toRight,
toLeft,
// If the anchor gets outside of the window on the left, we want to position
// toDisplayLeft for proximity to the anchor. Otherwise, toDisplayRight.
if (anchorBounds.left >= 0) toDisplayRight else toDisplayLeft,
)
} else {
sequenceOf(
toLeft,
toRight,
// If the anchor gets outside of the window on the right, we want to position
// toDisplayRight for proximity to the anchor. Otherwise, toDisplayLeft.
if (anchorBounds.right <= windowSize.width) toDisplayLeft else toDisplayRight,
)
}.firstOrNull {
it >= 0 && it + popupContentSize.width <= windowSize.width
} ?: toLeft
// Compute vertical position.
val toBottom = maxOf(anchorBounds.bottom + contentOffsetY, verticalMargin)
val toTop = anchorBounds.top - contentOffsetY - popupContentSize.height
val toCenter = anchorBounds.top - popupContentSize.height / 2
val toDisplayBottom = windowSize.height - popupContentSize.height - verticalMargin
val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull {
it >= verticalMargin &&
it + popupContentSize.height <= windowSize.height - verticalMargin
} ?: toTop
onPositionCalculated(
anchorBounds,
IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height),
)
return IntOffset(x, y)
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components
import androidx.compose.runtime.Composable
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig
@Composable
internal fun TokenDetailsDialogs(state: TokenDetailsState) {
val dialogConfig = state.dialogConfig
if (dialogConfig != null && dialogConfig.isShow) {
TokenDetailsDialog(config = dialogConfig)
}
}
@Composable
private fun TokenDetailsDialog(config: TokenDetailsDialogConfig) {
BasicDialog(
message = config.content.message.resolveReference(),
confirmButton = DialogButton(
title = config.content.confirmButtonConfig.text.resolveReference(),
warning = config.content.confirmButtonConfig.warning,
onClick = config.content.confirmButtonConfig.onClick,
),
onDismissDialog = config.onDismissRequest,
title = config.content.title.resolveReference(),
dismissButton = config.content.cancelButtonConfig?.let { cancelButtonConfig ->
DialogButton(
title = cancelButtonConfig.text.resolveReference(),
warning = cancelButtonConfig.warning,
onClick = cancelButtonConfig.onClick,
)
},
)
}

View file

@ -1,17 +1,31 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPreviewData
import com.tangem.features.tokendetails.impl.R
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsAppBarMenuConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarConfig
import com.tangem.features.tokendetails.impl.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun TokenDetailsTopAppBar(config: TokenDetailsTopAppBarConfig) {
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
TopAppBar(
navigationIcon = {
IconButton(onClick = config.onBackClick) {
@ -24,13 +38,28 @@ internal fun TokenDetailsTopAppBar(config: TokenDetailsTopAppBarConfig) {
},
title = {},
actions = {
IconButton(onClick = config.onMoreClick) {
IconButton(onClick = { showDropdownMenu = true }) {
Icon(
painter = painterResource(id = R.drawable.ic_more_vertical_24),
tint = TangemTheme.colors.icon.primary1,
contentDescription = "More",
)
}
TangemDropdownMenu(
expanded = showDropdownMenu,
modifier = Modifier.background(TangemTheme.colors.background.primary),
onDismissRequest = { showDropdownMenu = false },
offset = DpOffset(x = TangemTheme.dimens.spacing20, y = TangemTheme.dimens.spacing10.times(-1)),
content = {
config.tokenDetailsAppBarMenuConfig.items.fastForEach {
AppBarDropdownItem(
item = it,
dismissParent = { showDropdownMenu = false },
)
}
},
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = TangemTheme.colors.background.secondary,
@ -41,6 +70,57 @@ internal fun TokenDetailsTopAppBar(config: TokenDetailsTopAppBarConfig) {
)
}
@Suppress("ComposableEventParameterNaming")
@Composable
private fun AppBarDropdownItem(
item: TokenDetailsAppBarMenuConfig.MenuItem,
dismissParent: () -> Unit,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier
.clickable {
dismissParent()
item.onClick()
}
.padding(vertical = TangemTheme.dimens.spacing8, horizontal = TangemTheme.dimens.spacing16),
text = item.title.resolveReference(),
style = TangemTheme.typography.body1.copy(color = item.textColorProvider()),
)
}
@Preview
@Composable
private fun Preview_TokenDetailsAppBarDropdownItem_LightTheme() {
TangemTheme(isDark = false) {
AppBarDropdownItem(
modifier = Modifier.background(TangemTheme.colors.background.primary),
dismissParent = {},
item = TokenDetailsAppBarMenuConfig.MenuItem(
title = TextReference.Res(id = R.string.token_details_hide_token),
textColorProvider = { TangemTheme.colors.text.warning },
onClick = { },
),
)
}
}
@Preview
@Composable
private fun Preview_TokenDetailsAppBarDropdownItem_DarkTheme() {
TangemTheme(isDark = true) {
AppBarDropdownItem(
modifier = Modifier.background(TangemTheme.colors.background.primary),
dismissParent = {},
item = TokenDetailsAppBarMenuConfig.MenuItem(
title = TextReference.Res(id = R.string.token_details_hide_token),
textColorProvider = { TangemTheme.colors.text.warning },
onClick = { },
),
)
}
}
@Preview
@Composable
private fun Preview_TokenDetailsTopAppBar_LightTheme() {

View file

@ -6,8 +6,6 @@ interface TokenDetailsClickIntents : TxHistoryClickIntents {
fun onBackClick()
fun onMoreClick()
fun onSendClick()
fun onReceiveClick()
@ -15,4 +13,10 @@ interface TokenDetailsClickIntents : TxHistoryClickIntents {
fun onSellClick()
fun onSwapClick()
fun onDismissDialog()
fun onHideClick()
fun onHideConfirmed()
}

View file

@ -12,6 +12,8 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase
import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase
import com.tangem.domain.tokens.GetNetworkCoinStatusUseCase
import com.tangem.domain.tokens.RemoveCurrencyUseCase
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.models.CryptoCurrency
@ -31,6 +33,7 @@ import com.tangem.utils.coroutines.saveIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.properties.Delegates
@ -45,6 +48,8 @@ internal class TokenDetailsViewModel @Inject constructor(
private val txHistoryItemsUseCase: GetTxHistoryItemsUseCase,
private val getExploreUrlUseCase: GetExploreUrlUseCase,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase,
private val removeCurrencyUseCase: RemoveCurrencyUseCase,
private val getNetworkCoinStatusUseCase: GetNetworkCoinStatusUseCase,
private val reduxStateHolder: ReduxStateHolder,
savedStateHandle: SavedStateHandle,
) : ViewModel(), DefaultLifecycleObserver, TokenDetailsClickIntents {
@ -84,12 +89,12 @@ internal class TokenDetailsViewModel @Inject constructor(
private fun updateContent(selectedWallet: UserWallet) {
updateMarketPrice(selectedWallet = selectedWallet)
updateButtons(userWalletId = selectedWallet.walletId, currencyId = cryptoCurrency.id.value)
updateButtons(userWalletId = selectedWallet.walletId, currency = cryptoCurrency)
updateTxHistory()
}
private fun updateButtons(userWalletId: UserWalletId, currencyId: String) {
getCryptoCurrencyActionsUseCase(userWalletId = userWalletId, tokenId = currencyId)
private fun updateButtons(userWalletId: UserWalletId, currency: CryptoCurrency) {
getCryptoCurrencyActionsUseCase(userWalletId = userWalletId, cryptoCurrency = currency)
.distinctUntilChanged()
.onEach { uiState = stateFactory.getManageButtonsState(actions = it.states) }
.flowOn(dispatchers.io)
@ -149,10 +154,6 @@ internal class TokenDetailsViewModel @Inject constructor(
router.popBackStack()
}
override fun onMoreClick() {
TODO("Not yet implemented")
}
override fun onBuyClick() {
val status = cryptoCurrencyStatus ?: return
@ -170,7 +171,40 @@ internal class TokenDetailsViewModel @Inject constructor(
}
override fun onSendClick() {
reduxStateHolder.dispatch(TradeCryptoAction.New.Send)
val cryptoCurrencyStatus = cryptoCurrencyStatus ?: return
when (cryptoCurrencyStatus.currency) {
is CryptoCurrency.Coin -> {
reduxStateHolder.dispatch(
action = TradeCryptoAction.New.SendCoin(
userWallet = wallet,
coinStatus = cryptoCurrencyStatus,
),
)
}
is CryptoCurrency.Token -> sendToken(status = cryptoCurrencyStatus)
}
}
private fun sendToken(status: CryptoCurrencyStatus) {
viewModelScope.launch(dispatchers.io) {
getNetworkCoinStatusUseCase(
userWalletId = wallet.walletId,
networkId = status.currency.network.id,
)
.take(count = 1)
.collectLatest {
it.onRight { coinStatus ->
reduxStateHolder.dispatch(
action = TradeCryptoAction.New.SendToken(
userWallet = wallet,
tokenStatus = status,
coinFiatRate = coinStatus.value.fiatRate,
),
)
}
}
}
}
override fun onReceiveClick() {
@ -191,6 +225,29 @@ internal class TokenDetailsViewModel @Inject constructor(
reduxStateHolder.dispatch(TradeCryptoAction.New.Swap(cryptoCurrency))
}
override fun onDismissDialog() {
uiState = stateFactory.getStateWithClosedDialog()
}
override fun onHideClick() {
viewModelScope.launch {
val hasLinkedTokens = removeCurrencyUseCase.hasLinkedTokens(wallet.walletId, cryptoCurrency)
uiState = if (hasLinkedTokens) {
stateFactory.getStateWithLinkedTokensDialog(cryptoCurrency)
} else {
stateFactory.getStateWithConfirmHideTokenDialog(cryptoCurrency)
}
}
}
override fun onHideConfirmed() {
viewModelScope.launch {
removeCurrencyUseCase.invoke(wallet.walletId, cryptoCurrency)
.onLeft { Timber.e(it) }
.onRight { router.popBackStack() }
}
}
override fun onExploreClick() {
viewModelScope.launch {
router.openUrl(

View file

@ -8,13 +8,18 @@ import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.event.consumed
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.feature.wallet.presentation.common.state.TokenItemState.TokenOptionsState
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensListState
import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensState
import com.tangem.feature.wallet.presentation.wallet.state.*
import com.tangem.feature.wallet.presentation.wallet.state.ActionsBottomSheetConfig
import com.tangem.feature.wallet.presentation.wallet.state.TokenActionButtonConfig
import com.tangem.feature.wallet.presentation.wallet.state.WalletMultiCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletSingleCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.components.*
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletTokensListState.TokensListItemState
import kotlinx.collections.immutable.persistentListOf
@ -87,12 +92,34 @@ internal object WalletPreviewData {
)
}
private val coinIconState
get() = TokenItemState.IconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_polygon_22,
isGrayscale = false,
)
private val tokenIconState
get() = TokenItemState.IconState.TokenIcon(
url = null,
networkBadgeIconResId = R.drawable.img_polygon_22,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
)
private val customTokenIconState
get() = TokenItemState.IconState.CustomTokenIcon(
tint = TangemColorPalette.Black,
background = TangemColorPalette.Meadow,
networkBadgeIconResId = R.drawable.img_polygon_22,
isGrayscale = false,
)
val tokenItemVisibleState by lazy {
TokenItemState.Content(
id = UUID.randomUUID().toString(),
tokenIconUrl = null,
tokenIconResId = R.drawable.img_polygon_22,
networkBadgeIconResId = R.drawable.img_polygon_22,
icon = coinIconState,
name = "Polygon",
amount = "5,412 MATIC",
hasPending = true,
@ -103,7 +130,6 @@ internal object WalletPreviewData {
type = PriceChangeConfig.Type.UP,
),
),
isTestnet = false,
onItemClick = {},
onItemLongClick = {},
)
@ -112,16 +138,14 @@ internal object WalletPreviewData {
val testnetTokenItemVisibleState by lazy {
tokenItemVisibleState.copy(
name = "Polygon testnet",
isTestnet = true,
icon = tokenIconState.copy(isGrayscale = true),
)
}
val tokenItemHiddenState by lazy {
TokenItemState.Content(
id = UUID.randomUUID().toString(),
tokenIconUrl = null,
tokenIconResId = R.drawable.img_polygon_22,
networkBadgeIconResId = R.drawable.img_polygon_22,
icon = tokenIconState,
name = "Polygon",
amount = "5,412 MATIC",
hasPending = true,
@ -131,7 +155,6 @@ internal object WalletPreviewData {
type = PriceChangeConfig.Type.UP,
),
),
isTestnet = false,
onItemClick = {},
onItemLongClick = {},
)
@ -140,25 +163,37 @@ internal object WalletPreviewData {
val tokenItemDragState by lazy {
TokenItemState.Draggable(
id = UUID.randomUUID().toString(),
tokenIconUrl = null,
tokenIconResId = R.drawable.img_polygon_22,
networkBadgeIconResId = R.drawable.img_polygon_22,
icon = tokenIconState,
name = "Polygon",
isTestnet = false,
fiatAmount = "3 172,14 $",
info = stringReference(value = "3 172,14 $"),
)
}
val tokenItemUnreachableState by lazy {
TokenItemState.Unreachable(
id = UUID.randomUUID().toString(),
tokenIconUrl = null,
tokenIconResId = R.drawable.img_polygon_22,
networkBadgeIconResId = R.drawable.img_polygon_22,
icon = tokenIconState,
name = "Polygon",
)
}
val customTokenItemVisibleState by lazy {
tokenItemVisibleState.copy(
name = "Polygon custom",
icon = customTokenIconState.copy(
tint = TangemColorPalette.White,
background = TangemColorPalette.Black,
),
)
}
val customTestnetTokenItemVisibleState by lazy {
tokenItemVisibleState.copy(
name = "Polygon custom testnet",
icon = customTokenIconState.copy(isGrayscale = true),
)
}
val loadingTokenItemState by lazy { TokenItemState.Loading(id = "Loading#1") }
private const val networksSize = 10
@ -188,7 +223,6 @@ internal object WalletPreviewData {
tokenItemState = tokenItemDragState.copy(
id = "${group.id}_token_$tokenNumber",
name = "Token $tokenNumber from $networkNumber network",
networkBadgeIconResId = R.drawable.img_eth_22.takeIf { i != 0 },
),
groupId = group.id,
roundingMode = when {
@ -199,7 +233,7 @@ internal object WalletPreviewData {
)
}
val divider = DraggableItem.GroupPlaceholder(id = "divider_$networkNumber")
val divider = DraggableItem.Placeholder(id = "divider_$networkNumber")
buildList {
add(group)
@ -234,7 +268,7 @@ internal object WalletPreviewData {
),
dndConfig = OrganizeTokensState.DragAndDropConfig(
onItemDragged = { _, _ -> },
onDragStart = {},
onItemDragStart = {},
canDragItemOver = { _, _ -> false },
onItemDragEnd = {},
),
@ -299,8 +333,6 @@ internal object WalletPreviewData {
tokenItemVisibleState.copy(
id = "token_1",
name = "Ethereum",
tokenIconResId = R.drawable.img_eth_22,
networkBadgeIconResId = null,
amount = "1,89340821 ETH",
),
),
@ -308,8 +340,6 @@ internal object WalletPreviewData {
tokenItemVisibleState.copy(
id = "token_2",
name = "Ethereum",
tokenIconResId = R.drawable.img_eth_22,
networkBadgeIconResId = null,
amount = "1,89340821 ETH",
),
),
@ -317,8 +347,6 @@ internal object WalletPreviewData {
tokenItemVisibleState.copy(
id = "token_3",
name = "Ethereum",
tokenIconResId = R.drawable.img_eth_22,
networkBadgeIconResId = null,
amount = "1,89340821 ETH",
),
),
@ -326,8 +354,6 @@ internal object WalletPreviewData {
tokenItemVisibleState.copy(
id = "token_4",
name = "Ethereum",
tokenIconResId = R.drawable.img_eth_22,
networkBadgeIconResId = null,
amount = "1,89340821 ETH",
),
),
@ -336,13 +362,11 @@ internal object WalletPreviewData {
tokenItemVisibleState.copy(
id = "token_5",
name = "Ethereum",
tokenIconResId = R.drawable.img_eth_22,
networkBadgeIconResId = null,
amount = "1,89340821 ETH",
),
),
),
onOrganizeTokensClick = {},
organizeTokensButton = WalletTokensListState.OrganizeTokensButtonState.Visible(isEnabled = true, {}),
),
pullToRefreshConfig = WalletPullToRefreshConfig(
isRefreshing = false,

View file

@ -3,11 +3,15 @@ package com.tangem.feature.wallet.presentation.common.component
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.tooling.preview.Preview
@ -17,37 +21,21 @@ import androidx.constraintlayout.compose.ConstrainedLayoutReference
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.ConstraintLayoutScope
import androidx.constraintlayout.compose.Dimension
import com.tangem.core.ui.components.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.presentation.common.WalletPreviewData
import com.tangem.feature.wallet.presentation.common.component.token.TokenCryptoInfoBlock
import com.tangem.feature.wallet.presentation.common.component.token.TokenFiatInfoBlock
import com.tangem.feature.wallet.presentation.common.component.token.TokenIcon
import com.tangem.feature.wallet.presentation.common.component.token.icon.TokenIcon
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import org.burnoutcrew.reorderable.ReorderableLazyListState
// TODO: Add custom token state: [REDACTED_JIRA]
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun TokenItem(
state: TokenItemState,
modifier: Modifier = Modifier,
reorderableTokenListState: ReorderableLazyListState? = null,
) {
val hapticFeedback = LocalHapticFeedback.current
val containerModifier: Modifier = remember(state) {
when (state) {
is TokenItemState.Content -> modifier.combinedClickable(
onClick = state.onItemClick,
onLongClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
state.onItemLongClick()
},
)
else -> modifier
}
}
BaseContainer(modifier = containerModifier) {
BaseContainer(modifier = modifier.tokenClickable(state)) {
val (iconRef, cryptoInfoRef, fiatInfoRef) = createRefs()
TokenIcon(
@ -90,7 +78,7 @@ private inline fun BaseContainer(
) {
ConstraintLayout(
modifier = Modifier
.fillMaxSize()
.fillMaxWidth()
.padding(
horizontal = TangemTheme.dimens.spacing14,
vertical = TangemTheme.dimens.spacing14,
@ -110,8 +98,32 @@ private fun Modifier.constrainAsOptionsItem(scope: ConstraintLayoutScope, ref: C
}
}
// region preview
@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.tokenClickable(state: TokenItemState): Modifier = composed {
when (state) {
is TokenItemState.Content -> {
val hapticFeedback = LocalHapticFeedback.current
val onLongClick = remember(state) {
{
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
state.onItemLongClick()
}
}
this.combinedClickable(
onClick = state.onItemClick,
onLongClick = onLongClick,
)
}
is TokenItemState.Draggable,
is TokenItemState.Unreachable,
is TokenItemState.Loading,
is TokenItemState.Locked,
-> this
}
}
// region preview
@Preview
@Composable
private fun Preview_Tokens_LightTheme(@PreviewParameter(TokenConfigProvider::class) state: TokenItemState) {
@ -136,6 +148,8 @@ private class TokenConfigProvider : CollectionPreviewParameterProvider<TokenItem
WalletPreviewData.tokenItemHiddenState,
WalletPreviewData.loadingTokenItemState,
WalletPreviewData.testnetTokenItemVisibleState,
WalletPreviewData.customTokenItemVisibleState,
WalletPreviewData.customTestnetTokenItemVisibleState,
),
)

View file

@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemTypography
import com.tangem.feature.wallet.impl.R
@ -40,7 +41,7 @@ private fun ContentBlock(state: TokenItemState.ContentState, modifier: Modifier
AmountText(
amount = when (state) {
is TokenItemState.Content -> if (state.tokenOptions is TokenOptionsState.Hidden) DOTS else state.amount
is TokenItemState.Draggable -> state.fiatAmount
is TokenItemState.Draggable -> state.info.resolveReference()
is TokenItemState.Unreachable -> null
},
)

View file

@ -1,152 +0,0 @@
package com.tangem.feature.wallet.presentation.common.component.token
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
private const val GRAY_SCALE_SATURATION = 0f
@Composable
internal fun TokenIcon(state: TokenItemState, modifier: Modifier = Modifier) {
when (state) {
is TokenItemState.ContentState -> ContentIcon(content = state, modifier = modifier)
is TokenItemState.Loading -> LoadingIcon(modifier = modifier)
is TokenItemState.Locked -> LockedIcon(modifier = modifier)
}
}
@Composable
private fun ContentIcon(content: TokenItemState.ContentState, modifier: Modifier = Modifier) {
BaseContainer(modifier = modifier) {
val isTestnet = when (content) {
is TokenItemState.Content -> content.isTestnet
is TokenItemState.Draggable -> content.isTestnet
is TokenItemState.Unreachable -> false
}
val colorFilter = remember(isTestnet) {
if (isTestnet) {
ColorFilter.colorMatrix(
colorMatrix = ColorMatrix().apply { setToSaturation(GRAY_SCALE_SATURATION) },
)
} else {
null
}
}
Icon(
content = content,
colorFilter = colorFilter,
modifier = Modifier.align(Alignment.BottomStart),
)
NetworkBadge(
iconResId = content.networkBadgeIconResId,
colorFilter = colorFilter,
modifier = Modifier.align(Alignment.TopEnd),
)
}
}
@Composable
private fun Icon(content: TokenItemState.ContentState, colorFilter: ColorFilter?, modifier: Modifier = Modifier) {
val iconUrl = content.tokenIconUrl
val iconData: Any = remember(iconUrl) {
if (iconUrl.isNullOrEmpty()) content.tokenIconResId else iconUrl
}
SubcomposeAsyncImage(
modifier = modifier.iconSize(),
model = ImageRequest.Builder(context = LocalContext.current)
.data(data = iconData)
.placeholder(drawableResId = content.tokenIconResId)
.error(drawableResId = content.tokenIconResId)
.fallback(drawableResId = content.tokenIconResId)
.crossfade(enable = true)
.build(),
colorFilter = colorFilter,
contentDescription = null,
)
}
@Composable
private fun BoxScope.NetworkBadge(
@DrawableRes iconResId: Int?,
colorFilter: ColorFilter?,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = iconResId != null,
modifier = modifier
.size(TangemTheme.dimens.size18)
.background(color = TangemTheme.colors.background.primary, shape = CircleShape),
) {
if (iconResId == null) return@AnimatedVisibility
Image(
modifier = Modifier
.padding(all = TangemTheme.dimens.spacing2)
.align(Alignment.Center),
painter = painterResource(id = iconResId),
colorFilter = colorFilter,
contentDescription = null,
)
}
}
@Composable
private fun LoadingIcon(modifier: Modifier = Modifier) {
BaseContainer(modifier) {
CircleShimmer(
modifier = Modifier
.iconSize()
.align(alignment = Alignment.BottomStart),
)
}
}
@Composable
private fun LockedIcon(modifier: Modifier = Modifier) {
BaseContainer(modifier) {
Box(
modifier = Modifier
.iconSize()
.align(Alignment.BottomStart),
) {
Box(
modifier = Modifier
.matchParentSize()
.background(color = TangemTheme.colors.background.secondary, shape = CircleShape),
)
}
}
}
@Composable
private inline fun BaseContainer(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
Box(modifier = modifier.size(size = TangemTheme.dimens.size40), content = content)
}
private fun Modifier.iconSize(): Modifier = composed {
return@composed this.size(size = TangemTheme.dimens.size36)
}

View file

@ -0,0 +1,129 @@
package com.tangem.feature.wallet.presentation.common.component.token.icon
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
@Composable
internal fun ContentIcon(icon: TokenItemState.IconState, colorFilter: ColorFilter?, modifier: Modifier = Modifier) {
when (icon) {
is TokenItemState.IconState.CoinIcon -> CoinIcon(
modifier = modifier,
url = icon.url,
fallbackResId = icon.fallbackResId,
colorFilter = colorFilter,
)
is TokenItemState.IconState.TokenIcon -> TokenIcon(
modifier = modifier,
url = icon.url,
colorFilter = colorFilter,
errorIcon = {
CustomTokenIcon(
modifier = modifier,
tint = icon.fallbackTint,
background = icon.fallbackBackground,
)
},
)
is TokenItemState.IconState.CustomTokenIcon -> CustomTokenIcon(
modifier = modifier,
tint = icon.tint,
background = icon.background,
)
}
}
@Composable
private fun CoinIcon(
url: String?,
@DrawableRes fallbackResId: Int,
colorFilter: ColorFilter?,
modifier: Modifier = Modifier,
) {
val iconData: Any = if (url.isNullOrBlank()) fallbackResId else url
DefaultCurrencyIcon(
modifier = modifier,
iconData = iconData,
errorIcon = {
Image(
painter = painterResource(id = fallbackResId),
colorFilter = colorFilter,
contentDescription = null,
)
},
colorFilter = colorFilter,
)
}
@Composable
private fun TokenIcon(
url: String?,
colorFilter: ColorFilter?,
errorIcon: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
if (url == null) {
errorIcon()
} else {
DefaultCurrencyIcon(
modifier = modifier,
iconData = url,
errorIcon = errorIcon,
colorFilter = colorFilter,
)
}
}
@Composable
private fun CustomTokenIcon(tint: Color, background: Color, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.background(
color = background,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.matchParentSize(),
painter = painterResource(id = R.drawable.ic_custom_token_44),
tint = tint,
contentDescription = null,
)
}
}
@Composable
private inline fun DefaultCurrencyIcon(
iconData: Any,
colorFilter: ColorFilter?,
crossinline errorIcon: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
SubcomposeAsyncImage(
modifier = modifier,
model = ImageRequest.Builder(context = LocalContext.current)
.data(iconData)
.crossfade(enable = true)
.build(),
loading = { LoadingIcon() },
error = { errorIcon() },
colorFilter = colorFilter,
contentDescription = null,
)
}

View file

@ -0,0 +1,57 @@
package com.tangem.feature.wallet.presentation.common.component.token.icon
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun NetworkBadge(@DrawableRes iconResId: Int, colorFilter: ColorFilter?, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(TangemTheme.dimens.size18)
.background(
color = TangemTheme.colors.background.primary,
shape = CircleShape,
),
) {
Image(
modifier = Modifier
.padding(all = TangemTheme.dimens.spacing2)
.matchParentSize(),
painter = painterResource(id = iconResId),
colorFilter = colorFilter,
contentDescription = null,
)
}
}
@Composable
internal fun CustomBadge(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(TangemTheme.dimens.size12)
.background(
color = TangemTheme.colors.background.primary,
shape = CircleShape,
),
) {
Box(
modifier = Modifier
.padding(all = TangemTheme.dimens.spacing2)
.matchParentSize()
.background(
color = TangemTheme.colors.icon.informative,
shape = CircleShape,
),
)
}
}

View file

@ -0,0 +1,95 @@
package com.tangem.feature.wallet.presentation.common.component.token.icon
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
private const val GRAY_SCALE_SATURATION = 0f
@Composable
internal fun TokenIcon(state: TokenItemState, modifier: Modifier = Modifier) {
BaseContainer(modifier = modifier) {
val iconModifier = Modifier
.align(Alignment.Center)
.size(TangemTheme.dimens.size36)
when (state) {
is TokenItemState.Loading -> LoadingIcon(modifier = iconModifier)
is TokenItemState.Locked -> LockedIcon(modifier = iconModifier)
is TokenItemState.ContentState -> ContentIconContainer(
modifier = iconModifier,
icon = state.icon,
)
}
}
}
@Composable
internal fun LoadingIcon(modifier: Modifier = Modifier) {
CircleShimmer(modifier = modifier)
}
@Composable
private fun LockedIcon(modifier: Modifier = Modifier) {
Box(modifier = modifier) {
Box(
modifier = Modifier
.matchParentSize()
.background(
color = TangemTheme.colors.background.secondary,
shape = CircleShape,
),
)
}
}
@Composable
private fun BoxScope.ContentIconContainer(icon: TokenItemState.IconState, modifier: Modifier = Modifier) {
val networkBadgeOffset = TangemTheme.dimens.spacing4
val colorFilter = remember(icon.isGrayscale) {
if (icon.isGrayscale) {
ColorFilter.colorMatrix(
colorMatrix = ColorMatrix().apply { setToSaturation(GRAY_SCALE_SATURATION) },
)
} else {
null
}
}
ContentIcon(
modifier = modifier,
icon = icon,
colorFilter = colorFilter,
)
if (icon.networkBadgeIconResId != null) {
NetworkBadge(
modifier = Modifier
.offset(x = networkBadgeOffset, y = -networkBadgeOffset)
.align(Alignment.TopEnd),
iconResId = requireNotNull(icon.networkBadgeIconResId),
colorFilter = colorFilter,
)
}
if (icon is TokenItemState.IconState.CustomTokenIcon) {
CustomBadge(modifier = Modifier.align(Alignment.BottomEnd))
}
}
@Composable
private inline fun BaseContainer(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
Box(modifier = modifier.size(size = TangemTheme.dimens.size40), content = content)
}

View file

@ -2,7 +2,9 @@ package com.tangem.feature.wallet.presentation.common.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.components.marketprice.PriceChangeConfig
import com.tangem.core.ui.extensions.TextReference
/** Token item state */
@Immutable
@ -18,80 +20,121 @@ internal sealed interface TokenItemState {
data class Locked(override val id: String) : TokenItemState
/** Content state */
sealed class ContentState(
override val id: String,
open val tokenIconUrl: String?,
@DrawableRes open val tokenIconResId: Int,
@DrawableRes open val networkBadgeIconResId: Int?,
open val name: String,
) : TokenItemState
@Immutable
sealed class ContentState : TokenItemState {
abstract val icon: IconState
abstract val name: String
}
/**
* Content token state
*
* @property id unique id
* @property tokenIconUrl token icon url
* @property tokenIconResId token icon resource id
* @property networkBadgeIconResId network badge icon resource id, may be null if it is a coin
* @property icon token icon state
* @property name token name
* @property amount amount of token
* @property hasPending pending tx in blockchain
* @property tokenOptions state for token options
* @property isTestnet indicates whether the token is from test network or not
* @property onItemClick callback which will be called when an item is clicked
* @property onItemLongClick callback which will be called when an item is long clicked
*/
data class Content(
override val id: String,
override val tokenIconUrl: String?,
@DrawableRes override val tokenIconResId: Int,
@DrawableRes override val networkBadgeIconResId: Int?,
override val icon: IconState,
override val name: String,
val amount: String,
val hasPending: Boolean,
val tokenOptions: TokenOptionsState,
val isTestnet: Boolean,
val onItemClick: () -> Unit,
val onItemLongClick: () -> Unit,
) : ContentState(id, tokenIconUrl, tokenIconResId, networkBadgeIconResId, name)
) : ContentState()
/**
* Draggable token state
*
* @property id unique id
* @property tokenIconUrl token icon url
* @property tokenIconResId token icon resource id
* @property networkBadgeIconResId network badge icon resource id, may be null if it is a coin
* @property icon token icon state
* @property name token name
* @property fiatAmount fiat amount of token
* @property isTestnet indicates whether the token is from test network or not
* @property info token info (e.g. fiat balance or status)
*/
data class Draggable(
override val id: String,
override val tokenIconUrl: String?,
@DrawableRes override val tokenIconResId: Int,
@DrawableRes override val networkBadgeIconResId: Int?,
override val icon: IconState,
override val name: String,
val fiatAmount: String,
val isTestnet: Boolean,
) : ContentState(id, tokenIconUrl, tokenIconResId, networkBadgeIconResId, name)
val info: TextReference,
) : ContentState()
/**
* Unreachable token state
*
* @property id token id
* @property tokenIconUrl token icon url
* @property tokenIconResId token icon resource id
* @property networkBadgeIconResId network badge icon resource id, may be null if it is a coin
* @property icon token icon state
* @property name token name
*/
data class Unreachable(
override val id: String,
override val tokenIconUrl: String?,
@DrawableRes override val tokenIconResId: Int,
@DrawableRes override val networkBadgeIconResId: Int?,
override val icon: IconState,
override val name: String,
) : ContentState(id, tokenIconUrl, tokenIconResId, networkBadgeIconResId, name)
) : ContentState()
/**
* Represents the various states an icon can be in.
*/
@Immutable
sealed class IconState {
abstract val networkBadgeIconResId: Int?
abstract val isGrayscale: Boolean
/**
* Represents a coin icon.
*
* @property url The URL where the coin icon can be fetched from. May be `null` if not found.
* @property fallbackResId The drawable resource ID to be used as a fallback if the URL is not available.
* @property isGrayscale Specifies whether to show the icon in grayscale.
*/
data class CoinIcon(
val url: String?,
@DrawableRes val fallbackResId: Int,
override val isGrayscale: Boolean,
) : IconState() {
override val networkBadgeIconResId: Int? = null
}
/**
* Represents a token icon.
*
* @property url The URL where the token icon can be fetched from. May be `null` if not found.
* @property networkBadgeIconResId The drawable resource ID for the network badge.
* @property isGrayscale Specifies whether to show the icon in grayscale.
* @property fallbackTint The color to be used for tinting the fallback icon.
* @property fallbackBackground The background color to be used for the fallback icon.
*/
data class TokenIcon(
val url: String?,
@DrawableRes override val networkBadgeIconResId: Int,
override val isGrayscale: Boolean,
val fallbackTint: Color,
val fallbackBackground: Color,
) : IconState()
/**
* Represents a custom token icon.
*
* @property tint The color to be used for tinting the icon.
* @property background The background color to be used for the icon.
* @property networkBadgeIconResId The drawable resource ID for the network badge.
* @property isGrayscale Specifies whether to show the icon in grayscale.
*/
data class CustomTokenIcon(
val tint: Color,
val background: Color,
@DrawableRes override val networkBadgeIconResId: Int,
override val isGrayscale: Boolean,
) : IconState()
}
/** Token options state */
@Immutable

View file

@ -0,0 +1,52 @@
package com.tangem.feature.wallet.presentation.common.utils
import com.tangem.core.ui.extensions.getTintForTokenIcon
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.models.CryptoCurrency
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.utils.converter.Converter
internal class CryptoCurrencyToIconStateConverter : Converter<CryptoCurrencyStatus, TokenItemState.IconState> {
override fun convert(value: CryptoCurrencyStatus): TokenItemState.IconState {
return when (val currency = value.currency) {
is CryptoCurrency.Coin -> getIconStateForCoin(currency, value.value.isError)
is CryptoCurrency.Token -> getIconStateForToken(currency, value.value.isError)
}
}
private fun getIconStateForCoin(
coin: CryptoCurrency.Coin,
isUnreachable: Boolean,
): TokenItemState.IconState.CoinIcon {
return TokenItemState.IconState.CoinIcon(
url = coin.iconUrl,
fallbackResId = coin.networkIconResId,
isGrayscale = coin.network.isTestnet || isUnreachable,
)
}
private fun getIconStateForToken(token: CryptoCurrency.Token, isErrorStatus: Boolean): TokenItemState.IconState {
val background = token.tryGetBackgroundForTokenIcon()
val tint = getTintForTokenIcon(background)
return if (token.isCustom) {
TokenItemState.IconState.CustomTokenIcon(
tint = tint,
background = background,
networkBadgeIconResId = token.networkIconResId,
isGrayscale = token.network.isTestnet || isErrorStatus,
)
} else {
TokenItemState.IconState.TokenIcon(
url = token.iconUrl,
networkBadgeIconResId = token.networkIconResId,
isGrayscale = token.network.isTestnet || isErrorStatus,
fallbackTint = tint,
fallbackBackground = background,
)
}
}
}

View file

@ -1,8 +1,7 @@
package com.tangem.feature.wallet.presentation.organizetokens
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
@ -19,10 +18,13 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
@ -37,7 +39,10 @@ import com.tangem.feature.wallet.presentation.common.component.TokenItem
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensListState
import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensState
import org.burnoutcrew.reorderable.*
import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.ReorderableLazyListState
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable
@Composable
internal fun OrganizeTokensScreen(state: OrganizeTokensState, modifier: Modifier = Modifier) {
@ -91,27 +96,28 @@ private fun TokenList(
canDragOver = dndConfig.canDragItemOver,
onDragEnd = onDragEnd,
)
val items = state.items
val listContentPadding = PaddingValues(
top = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing92,
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
)
LazyColumn(
modifier = Modifier
.reorderable(reorderableListState)
.align(Alignment.TopCenter)
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxSize(),
.reorderable(reorderableListState),
state = reorderableListState.listState,
contentPadding = PaddingValues(
top = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing92,
),
contentPadding = listContentPadding,
) {
itemsIndexed(
items = items,
items = state.items,
key = { _, item -> item.id },
) { index, item ->
val onDragStart = remember(item) {
{ dndConfig.onDragStart(item) }
{ dndConfig.onItemDragStart(item) }
}
DraggableItem(
@ -127,7 +133,6 @@ private fun TokenList(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun LazyItemScope.DraggableItem(
index: Int,
@ -135,15 +140,18 @@ private fun LazyItemScope.DraggableItem(
reorderableState: ReorderableLazyListState,
onDragStart: () -> Unit,
) {
var isDragging by remember {
mutableStateOf(value = false)
}
val itemModifier = Modifier.applyShapeAndShadow(item.roundingMode, item.showShadow)
ReorderableItem(
defaultDraggingModifier = Modifier.animateItemPlacement(
animationSpec = tween(easing = LinearOutSlowInEasing),
),
state = reorderableState,
reorderableState = reorderableState,
index = index,
key = item.id,
) { isDragging ->
val itemModifier = Modifier.applyShapeAndShadow(item.roundingMode, item.showShadow)
) { isItemDragging ->
isDragging = isItemDragging
when (item) {
is DraggableItem.GroupHeader -> DraggableNetworkGroupItem(
@ -157,10 +165,12 @@ private fun LazyItemScope.DraggableItem(
reorderableTokenListState = reorderableState,
)
// Should be presented in the list but remain invisible
is DraggableItem.GroupPlaceholder -> Box(modifier = Modifier.fillMaxWidth())
is DraggableItem.Placeholder -> Box(modifier = Modifier.fillMaxWidth())
}
}
LaunchedEffect(isDragging) {
DisposableEffect(isDragging) {
onDispose {
if (isDragging) {
onDragStart()
}
@ -283,57 +293,71 @@ private fun Actions(config: OrganizeTokensState.ActionsConfig, modifier: Modifie
}
}
private fun Modifier.applyShapeAndShadow(roundingMode: DraggableItem.RoundingMode, showShadow: Boolean): Modifier =
composed {
private fun Modifier.applyShapeAndShadow(roundingMode: DraggableItem.RoundingMode, showShadow: Boolean): Modifier {
return composed {
val radius by animateDpAsState(
targetValue = if (roundingMode !is DraggableItem.RoundingMode.None) {
TangemTheme.dimens.radius16
} else {
TangemTheme.dimens.radius0
targetValue = when (roundingMode) {
is DraggableItem.RoundingMode.None -> TangemTheme.dimens.radius0
is DraggableItem.RoundingMode.All -> TangemTheme.dimens.radius12
is DraggableItem.RoundingMode.Bottom,
is DraggableItem.RoundingMode.Top,
-> TangemTheme.dimens.radius16
},
label = "item_shape_radius",
)
val shape = when (roundingMode) {
is DraggableItem.RoundingMode.None -> RectangleShape
is DraggableItem.RoundingMode.Top -> RoundedCornerShape(
topStart = radius,
topEnd = radius,
)
is DraggableItem.RoundingMode.Bottom -> RoundedCornerShape(
bottomStart = radius,
bottomEnd = radius,
)
is DraggableItem.RoundingMode.All -> RoundedCornerShape(
size = radius,
)
}
val paddingValue = TangemTheme.dimens.spacing4
val padding = if (roundingMode.showGap) {
when (roundingMode) {
is DraggableItem.RoundingMode.None -> null
is DraggableItem.RoundingMode.All -> PaddingValues(vertical = paddingValue)
is DraggableItem.RoundingMode.Top -> PaddingValues(top = paddingValue)
is DraggableItem.RoundingMode.Bottom -> PaddingValues(bottom = paddingValue)
}
} else {
null
}
val elevation by animateDpAsState(
targetValue = if (showShadow) {
TangemTheme.dimens.elevation8
} else {
TangemTheme.dimens.elevation0
},
label = "item_elevation",
)
this
.let {
if (padding != null) {
it.padding(padding)
} else {
it
}
}
.padding(paddingValues = getItemGap(roundingMode))
.shadow(
elevation = if (showShadow) TangemTheme.dimens.elevation12 else TangemTheme.dimens.elevation0,
shape = shape,
elevation = elevation,
shape = getItemShape(roundingMode, radius),
clip = true,
)
}
}
@Composable
@ReadOnlyComposable
private fun getItemGap(roundingMode: DraggableItem.RoundingMode): PaddingValues {
val paddingValue = TangemTheme.dimens.spacing4
return if (roundingMode.showGap) {
when (roundingMode) {
is DraggableItem.RoundingMode.None -> PaddingValues(all = 0.dp)
is DraggableItem.RoundingMode.All -> PaddingValues(vertical = paddingValue)
is DraggableItem.RoundingMode.Top -> PaddingValues(top = paddingValue)
is DraggableItem.RoundingMode.Bottom -> PaddingValues(bottom = paddingValue)
}
} else {
PaddingValues(all = 0.dp)
}
}
@Stable
private fun getItemShape(roundingMode: DraggableItem.RoundingMode, radius: Dp): Shape {
return when (roundingMode) {
is DraggableItem.RoundingMode.None -> RectangleShape
is DraggableItem.RoundingMode.Top -> RoundedCornerShape(
topStart = radius,
topEnd = radius,
)
is DraggableItem.RoundingMode.Bottom -> RoundedCornerShape(
bottomStart = radius,
bottomEnd = radius,
)
is DraggableItem.RoundingMode.All -> RoundedCornerShape(
size = radius,
)
}
}
// region Preview

View file

@ -110,7 +110,7 @@ internal class OrganizeTokensStateHolder(
),
dndConfig = OrganizeTokensState.DragAndDropConfig(
onItemDragged = dragAndDropIntents::onItemDragged,
onDragStart = dragAndDropIntents::onItemDraggingStart,
onItemDragStart = dragAndDropIntents::onItemDraggingStart,
onItemDragEnd = dragAndDropIntents::onItemDraggingEnd,
canDragItemOver = dragAndDropIntents::canDragItemOver,
),

View file

@ -20,13 +20,14 @@ import com.tangem.feature.wallet.presentation.organizetokens.utils.common.disabl
import com.tangem.feature.wallet.presentation.organizetokens.utils.dnd.DragAndDropAdapter
import com.tangem.feature.wallet.presentation.router.InnerWalletRouter
import com.tangem.feature.wallet.presentation.router.WalletRoute
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@Suppress("LongParameterList")
@HiltViewModel
internal class OrganizeTokensViewModel @Inject constructor(
private val getTokenListUseCase: GetTokenListUseCase,
@ -34,6 +35,7 @@ internal class OrganizeTokensViewModel @Inject constructor(
private val toggleTokenListSortingUseCase: ToggleTokenListSortingUseCase,
private val applyTokenListSortingUseCase: ApplyTokenListSortingUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val dispatchers: CoroutineDispatcherProvider,
savedStateHandle: SavedStateHandle,
) : ViewModel(), OrganizeTokensIntents {
@ -43,7 +45,6 @@ internal class OrganizeTokensViewModel @Inject constructor(
private val dragAndDropAdapter = DragAndDropAdapter(
listStateProvider = Provider { uiState.value.itemsState },
scope = viewModelScope,
)
private val stateHolder = OrganizeTokensStateHolder(
@ -72,7 +73,7 @@ internal class OrganizeTokensViewModel @Inject constructor(
}
override fun onSortClick() {
viewModelScope.launch(Dispatchers.Default) {
viewModelScope.launch(dispatchers.default) {
val list = tokenList ?: return@launch
toggleTokenListSortingUseCase(list).fold(
@ -86,7 +87,7 @@ internal class OrganizeTokensViewModel @Inject constructor(
}
override fun onGroupClick() {
viewModelScope.launch(Dispatchers.Default) {
viewModelScope.launch(dispatchers.default) {
val list = tokenList ?: return@launch
toggleTokenListGroupingUseCase(list).fold(
@ -100,7 +101,7 @@ internal class OrganizeTokensViewModel @Inject constructor(
}
override fun onApplyClick() {
viewModelScope.launch(Dispatchers.Default) {
viewModelScope.launch(dispatchers.default) {
stateHolder.updateStateToDisplayProgress()
val listState = uiState.value.itemsState
@ -117,7 +118,9 @@ internal class OrganizeTokensViewModel @Inject constructor(
ifLeft = stateHolder::updateStateWithError,
ifRight = {
stateHolder.updateStateToHideProgress()
withContext(Dispatchers.Main) { router.popBackStack() }
withContext(
dispatchers.main,
) { router.popBackStack() }
},
)
}
@ -128,9 +131,9 @@ internal class OrganizeTokensViewModel @Inject constructor(
}
private fun bootstrapTokenList() {
viewModelScope.launch(Dispatchers.Default) {
viewModelScope.launch(dispatchers.default) {
val maybeTokenList = getTokenListUseCase(userWalletId)
.first { it.getOrNull()?.totalFiatBalance is TokenList.FiatBalance.Loaded }
.first { it.getOrNull()?.totalFiatBalance !is TokenList.FiatBalance.Loading }
maybeTokenList.fold(
ifLeft = stateHolder::updateStateWithError,

View file

@ -51,12 +51,11 @@ internal sealed class DraggableItem {
}
/**
* Helper item used to detect possible positions where a network group can be placed.
* Used only on [OrganizeTokensListState.GroupedByNetwork] and placed between network groups.
* Helper item used to detect possible positions where a draggable item can be placed.
*
* @property id ID of the placeholder
* */
data class GroupPlaceholder(
data class Placeholder(
override val id: String,
) : DraggableItem() {
override val showShadow: Boolean = false
@ -109,7 +108,7 @@ internal sealed class DraggableItem {
* @return updated [DraggableItem]
* */
fun updateRoundingMode(mode: RoundingMode): DraggableItem = when (this) {
is GroupPlaceholder -> this
is Placeholder -> this
is GroupHeader -> this.copy(roundingMode = mode)
is Token -> this.copy(roundingMode = mode)
}
@ -122,7 +121,7 @@ internal sealed class DraggableItem {
* @return updated [DraggableItem]
* */
fun updateShadowVisibility(show: Boolean): DraggableItem = when (this) {
is GroupPlaceholder -> this
is Placeholder -> this
is GroupHeader -> this.copy(showShadow = show)
is Token -> this.copy(showShadow = show)
}

View file

@ -13,7 +13,7 @@ internal sealed class OrganizeTokensListState {
) : OrganizeTokensListState()
data class Ungrouped(
override val items: PersistentList<DraggableItem.Token>,
override val items: PersistentList<DraggableItem>,
) : OrganizeTokensListState()
object Empty : OrganizeTokensListState() {

View file

@ -33,6 +33,6 @@ internal data class OrganizeTokensState(
val onItemDragged: (ItemPosition, ItemPosition) -> Unit,
val canDragItemOver: (ItemPosition, ItemPosition) -> Boolean,
val onItemDragEnd: () -> Unit,
val onDragStart: (DraggableItem) -> Unit,
val onItemDragStart: (DraggableItem) -> Unit,
)
}

View file

@ -11,7 +11,7 @@ internal class CryptoCurrenciesIdsResolver {
val draggableTokens = when (listState) {
is OrganizeTokensListState.Empty -> return emptyList()
is OrganizeTokensListState.GroupedByNetwork -> listState.items.filterIsInstance<DraggableItem.Token>()
is OrganizeTokensListState.Ungrouped -> listState.items
is OrganizeTokensListState.Ungrouped -> listState.items.filterIsInstance<DraggableItem.Token>()
}
val currenciesStatuses = when (tokenList) {
is TokenList.GroupedByNetwork -> tokenList.groups.flatMap { it.currencies }

View file

@ -2,6 +2,6 @@ package com.tangem.feature.wallet.presentation.organizetokens.utils.common
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
internal fun getGroupPlaceholder(index: Int): DraggableItem.GroupPlaceholder {
return DraggableItem.GroupPlaceholder(id = "placeholder_${index.inc()}")
internal fun getGroupPlaceholder(index: Int): DraggableItem.Placeholder {
return DraggableItem.Placeholder(id = "placeholder_${index.inc()}")
}

View file

@ -3,20 +3,22 @@ package com.tangem.feature.wallet.presentation.organizetokens.utils.common
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
internal fun List<DraggableItem>.uniteItems(): List<DraggableItem> {
val lastItemIndex = this.lastIndex
val items = prepareItems()
val lastItemIndex = items.lastIndex
return this.mapIndexed { index, item ->
return prepareItems().mapIndexed { index, item ->
val mode = when (index) {
0 -> DraggableItem.RoundingMode.Top()
// 1 index is used because the first item is always a placeholder, check `prepareItems()` function
1 -> DraggableItem.RoundingMode.Top()
lastItemIndex -> DraggableItem.RoundingMode.Bottom()
else -> when (item) {
is DraggableItem.Placeholder -> DraggableItem.RoundingMode.None
is DraggableItem.GroupHeader -> DraggableItem.RoundingMode.Top(showGap = true)
is DraggableItem.Token -> if (this[index + 1] is DraggableItem.GroupPlaceholder) {
is DraggableItem.Token -> if (items[index + 1] is DraggableItem.Placeholder) {
DraggableItem.RoundingMode.Bottom(showGap = true)
} else {
DraggableItem.RoundingMode.None
}
is DraggableItem.GroupPlaceholder -> DraggableItem.RoundingMode.None
}
}
@ -24,4 +26,49 @@ internal fun List<DraggableItem>.uniteItems(): List<DraggableItem> {
.updateRoundingMode(mode)
.updateShadowVisibility(show = false)
}
}
internal fun List<DraggableItem>.divideMovingItem(movingItem: DraggableItem): List<DraggableItem> {
val mutableList = this.toMutableList()
val listIterator = mutableList.listIterator()
while (listIterator.hasNext()) {
val item = listIterator.next()
if (item.id == movingItem.id) {
val dividedItem = movingItem
.updateRoundingMode(DraggableItem.RoundingMode.All())
.updateShadowVisibility(show = true)
listIterator.set(dividedItem)
break
}
}
return mutableList
}
/**
* !!! Workaround !!!
*
* We need to add a [DraggableItem.Placeholder] (since it's not draggable) as the first item of the list, because the
* [DND library](https://github.com/aclassen/ComposeReorderable) glitches when a user tries to drag the first item.
*
* @since 07.09.2023
* */
private fun List<DraggableItem>.prepareItems(): List<DraggableItem> {
val firstPlaceholderId = "initial_placeholder"
val items = this
return mutableListOf<DraggableItem>().apply {
add(DraggableItem.Placeholder(firstPlaceholderId))
val itemsWithoutFirstPlaceholder = if (items.firstOrNull()?.id == firstPlaceholderId) {
items.drop(n = 1)
} else {
items
}
addAll(itemsWithoutFirstPlaceholder)
}
}

View file

@ -5,7 +5,6 @@ import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeToken
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
@Suppress("UNCHECKED_CAST")
internal inline fun OrganizeTokensListState.updateItems(
update: (PersistentList<DraggableItem>) -> List<DraggableItem>,
): OrganizeTokensListState {
@ -13,7 +12,7 @@ internal inline fun OrganizeTokensListState.updateItems(
return when (this) {
is OrganizeTokensListState.GroupedByNetwork -> copy(items = updatedItems)
is OrganizeTokensListState.Ungrouped -> copy(items = updatedItems as PersistentList<DraggableItem.Token>)
is OrganizeTokensListState.Ungrouped -> copy(items = updatedItems)
is OrganizeTokensListState.Empty -> this
}
}

View file

@ -1,12 +1,14 @@
package com.tangem.feature.wallet.presentation.organizetokens.utils.converter.items
import com.tangem.common.Provider
import com.tangem.core.ui.extensions.iconResId
import com.tangem.core.ui.extensions.networkBadgeIconResId
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.feature.wallet.presentation.common.utils.CryptoCurrencyToIconStateConverter
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.getGroupHeaderId
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.getTokenItemId
@ -16,6 +18,8 @@ internal class CryptoCurrencyToDraggableItemConverter(
private val appCurrencyProvider: Provider<AppCurrency>,
) : Converter<CryptoCurrencyStatus, DraggableItem.Token> {
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: CryptoCurrencyStatus): DraggableItem.Token {
return createDraggableToken(value, appCurrencyProvider())
}
@ -44,12 +48,13 @@ internal class CryptoCurrencyToDraggableItemConverter(
return TokenItemState.Draggable(
id = getTokenItemId(currency.id),
tokenIconUrl = currency.iconUrl,
tokenIconResId = currencyStatus.currency.iconResId,
networkBadgeIconResId = currencyStatus.currency.networkBadgeIconResId,
icon = iconStateConverter.convert(currencyStatus),
name = currency.name,
fiatAmount = getFormattedFiatAmount(currencyStatus, appCurrency),
isTestnet = currencyStatus.currency.network.isTestnet,
info = if (currencyStatus.value.isError) {
resourceReference(id = R.string.common_unreachable)
} else {
stringReference(getFormattedFiatAmount(currencyStatus, appCurrency))
},
)
}

View file

@ -36,6 +36,6 @@ internal class NetworkGroupToDraggableItemsConverter(
)
private fun createTokens(group: NetworkGroup): List<DraggableItem.Token> {
return itemConverter.convertList(group.currencies.toList())
return itemConverter.convertList(group.currencies)
}
}

View file

@ -4,21 +4,17 @@ import com.tangem.common.Provider
import com.tangem.feature.wallet.presentation.organizetokens.DragAndDropIntents
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensListState
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.divideMovingItem
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.uniteItems
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.updateItems
import kotlinx.collections.immutable.mutate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import org.burnoutcrew.reorderable.ItemPosition
internal class DragAndDropAdapter(
private val listStateProvider: Provider<OrganizeTokensListState>,
private val scope: CoroutineScope,
) : DragAndDropIntents {
private val draggableGroupsOperations = DraggableGroupsOperations()
@ -37,9 +33,12 @@ internal class DragAndDropAdapter(
get() = listStateFlowInternal
override fun canDragItemOver(dragOver: ItemPosition, dragging: ItemPosition): Boolean {
val items = (currentListState as? OrganizeTokensListState.GroupedByNetwork)
?.items
?: return true // If ungrouped then item can be moved anywhere
val items = when (val listState = currentListState) {
is OrganizeTokensListState.GroupedByNetwork -> listState.items
is OrganizeTokensListState.Empty,
is OrganizeTokensListState.Ungrouped,
-> return true // If ungrouped then item can be moved anywhere
}
val (dragOverItem, draggingItem) = findItemsToMove(
items = items,
@ -54,7 +53,7 @@ internal class DragAndDropAdapter(
return when (draggingItem) {
is DraggableItem.GroupHeader -> checkCanMoveHeaderOver(dragOver, dragOverItem, items.lastIndex)
is DraggableItem.Token -> checkCanMoveTokenOver(draggingItem, dragOverItem)
is DraggableItem.GroupPlaceholder -> false
is DraggableItem.Placeholder -> false
}
}
@ -64,11 +63,11 @@ internal class DragAndDropAdapter(
updateListState {
when (item) {
is DraggableItem.GroupPlaceholder -> items
is DraggableItem.Placeholder -> items
is DraggableItem.GroupHeader -> draggableGroupsOperations.collapseGroup(items, item)
is DraggableItem.Token -> when (this) {
is OrganizeTokensListState.GroupedByNetwork -> draggableGroupsOperations.divideGroups(items, item)
is OrganizeTokensListState.Ungrouped -> divideTokens(items, item)
is OrganizeTokensListState.GroupedByNetwork -> items.divideMovingItem(item)
is OrganizeTokensListState.Ungrouped -> items.divideMovingItem(item)
is OrganizeTokensListState.Empty -> items
}
}
@ -76,21 +75,17 @@ internal class DragAndDropAdapter(
}
override fun onItemDraggingEnd() {
scope.launch(Dispatchers.IO) {
val draggingItem = currentDraggingItem ?: return@launch
val draggingItem = currentDraggingItem ?: return
delay(FINISH_DRAGGING_DELAY_MILLIS)
updateListState {
when (draggingItem) {
is DraggableItem.GroupHeader -> draggableGroupsOperations.expandGroups(items)
is DraggableItem.Token -> items.uniteItems()
is DraggableItem.GroupPlaceholder -> items
}
updateListState {
when (draggingItem) {
is DraggableItem.GroupHeader -> draggableGroupsOperations.expandGroups(items)
is DraggableItem.Token -> items.uniteItems()
is DraggableItem.Placeholder -> items
}
currentDraggingItem = null
}
currentDraggingItem = null
}
override fun onItemDragged(from: ItemPosition, to: ItemPosition) = updateListState {
@ -137,7 +132,7 @@ internal class DragAndDropAdapter(
return when {
moveOverItemPosition.index == 0 -> true
moveOverItemPosition.index == lastItemIndex -> true
moveOverItem is DraggableItem.GroupPlaceholder -> true
moveOverItem is DraggableItem.Placeholder -> true
else -> false
}
}
@ -147,23 +142,7 @@ internal class DragAndDropAdapter(
return when (moveOverItem) {
is DraggableItem.GroupHeader -> false // Token item can not be moved to group item
is DraggableItem.Token -> item.groupId == moveOverItem.groupId // Token item can not be moved over its group
is DraggableItem.GroupPlaceholder -> false
is DraggableItem.Placeholder -> false
}
}
@Suppress("UNCHECKED_CAST") // Erased type
private fun divideTokens(
items: List<DraggableItem.Token>,
movingItem: DraggableItem.Token,
): List<DraggableItem.Token> {
return items.map { token ->
token
.updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true))
.updateShadowVisibility(show = token.id == movingItem.id)
} as List<DraggableItem.Token>
}
private companion object {
const val FINISH_DRAGGING_DELAY_MILLIS = 200L
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.feature.wallet.presentation.organizetokens.utils.dnd
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.divideMovingItem
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.getGroupPlaceholder
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.uniteItems
@ -20,7 +21,7 @@ internal class DraggableGroupsOperations {
it is DraggableItem.Token && it.groupId == movingGroup.id
}
return divideGroups(itemsWithoutGroupTokens, movingGroup)
return itemsWithoutGroupTokens.divideMovingItem(movingGroup)
}
fun expandGroups(items: List<DraggableItem>): List<DraggableItem> {
@ -45,62 +46,4 @@ internal class DraggableGroupsOperations {
return expandedGroups
}
fun divideGroups(items: List<DraggableItem>, movingItem: DraggableItem): List<DraggableItem> {
val lastItemIndex = items.lastIndex
return items.mapIndexed { index, item ->
when {
// Case when current item is the moving item
item.id == movingItem.id -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true))
.updateShadowVisibility(show = true)
}
// Case when moving item is a token and current item is the group of the moving token
movingItem is DraggableItem.Token && item.id == movingItem.groupId -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true))
.updateShadowVisibility(show = true)
}
// Case when both moving item and current item are tokens and belong to the same group
movingItem is DraggableItem.Token &&
item is DraggableItem.Token && item.groupId == movingItem.groupId -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true))
.updateShadowVisibility(show = false)
}
// Case when current item is the first item in the list
index == 0 -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.Top())
.updateShadowVisibility(show = false)
}
// Case when current item is the last item in the list
index == lastItemIndex -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.Bottom())
.updateShadowVisibility(show = false)
}
// Case when previous item is a GroupPlaceholder
items[index - 1] is DraggableItem.GroupPlaceholder -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.Top(showGap = true))
.updateShadowVisibility(show = false)
}
// Case when next item is a GroupPlaceholder
items[index + 1] is DraggableItem.GroupPlaceholder -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.Bottom(showGap = true))
.updateShadowVisibility(show = false)
}
// Default case when none of the above conditions are met
else -> {
item
.updateRoundingMode(DraggableItem.RoundingMode.None)
.updateShadowVisibility(show = false)
}
}
}
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.feature.wallet.presentation.wallet.state.components.*
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
@ -19,7 +20,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
internal sealed class WalletSingleCurrencyState : WalletState.ContentState() {
/** Manage buttons */
abstract val buttons: ImmutableList<WalletManageButton>
abstract val buttons: PersistentList<WalletManageButton>
/** Transactions history state */
abstract val txHistoryState: TxHistoryState
@ -31,7 +32,7 @@ internal sealed class WalletSingleCurrencyState : WalletState.ContentState() {
override val pullToRefreshConfig: WalletPullToRefreshConfig,
override val notifications: ImmutableList<WalletNotification>,
override val bottomSheetConfig: WalletBottomSheetConfig?,
override val buttons: ImmutableList<WalletManageButton>,
override val buttons: PersistentList<WalletManageButton>,
override val txHistoryState: TxHistoryState,
val marketPriceBlockState: MarketPriceBlockState,
) : WalletSingleCurrencyState()
@ -41,7 +42,7 @@ internal sealed class WalletSingleCurrencyState : WalletState.ContentState() {
override val topBarConfig: WalletTopBarConfig,
override val walletsListConfig: WalletsListConfig,
override val pullToRefreshConfig: WalletPullToRefreshConfig,
override val buttons: ImmutableList<WalletManageButton>,
override val buttons: PersistentList<WalletManageButton>,
override val onUnlockWalletsNotificationClick: () -> Unit,
override val onUnlockClick: () -> Unit,
override val onScanClick: () -> Unit,

View file

@ -5,6 +5,7 @@ import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import javax.annotation.concurrent.Immutable
/**
* Wallet tokens list state
@ -20,11 +21,10 @@ internal sealed class WalletTokensListState {
* Wallet content token list state
*
* @property items content items
* @property onOrganizeTokensClick lambda be invoked when organize tokens button is clicked
*/
sealed class ContentState(
open val items: ImmutableList<TokensListItemState>,
open val onOrganizeTokensClick: (() -> Unit)?,
open val organizeTokensButton: OrganizeTokensButtonState,
) : WalletTokensListState()
/**
@ -37,18 +37,18 @@ internal sealed class WalletTokensListState {
TokensListItemState.Token(state = TokenItemState.Loading(id = FIRST_LOADING_TOKEN_ID)),
TokensListItemState.Token(state = TokenItemState.Loading(id = SECOND_LOADING_TOKEN_ID)),
),
) : ContentState(items = items, onOrganizeTokensClick = null)
) : ContentState(items = items, organizeTokensButton = OrganizeTokensButtonState.Hidden)
/**
* Content state
*
* @property items content items
* @property onOrganizeTokensClick lambda be invoked when organize tokens button is clicked
* @property organizeTokensButton represents the state of the 'Organize Tokens' button
*/
data class Content(
override val items: ImmutableList<TokensListItemState>,
override val onOrganizeTokensClick: (() -> Unit)?,
) : ContentState(items, onOrganizeTokensClick)
override val organizeTokensButton: OrganizeTokensButtonState,
) : ContentState(items, organizeTokensButton)
/** Locked content state */
object Locked : ContentState(
@ -56,10 +56,32 @@ internal sealed class WalletTokensListState {
TokensListItemState.NetworkGroupTitle(value = TextReference.Res(id = R.string.main_tokens)),
TokensListItemState.Token(state = TokenItemState.Locked(id = LOCKED_TOKEN_ID)),
),
onOrganizeTokensClick = null,
organizeTokensButton = OrganizeTokensButtonState.Hidden,
)
/**
* Represents the state of the 'Organize Tokens' button.
*/
@Immutable
sealed class OrganizeTokensButtonState {
/** Represents the state where the 'Organize Tokens' button is hidden. */
object Hidden : OrganizeTokensButtonState()
/**
* Represents the state where the 'Organize Tokens' button is visible.
*
* @property isEnabled Indicates if the button is enabled or not.
* @property onClick Callback to be executed when the button is clicked.
*/
data class Visible(
val isEnabled: Boolean,
val onClick: () -> Unit,
) : OrganizeTokensButtonState()
}
/** Tokens list item state */
@Immutable
sealed interface TokensListItemState {
/**

View file

@ -1,36 +1,32 @@
package com.tangem.feature.wallet.presentation.wallet.state.factory
import com.tangem.common.Provider
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.feature.wallet.presentation.wallet.state.TokenActionButtonConfig
import com.tangem.feature.wallet.presentation.wallet.state.WalletState
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletClickIntents
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Converter from loaded [TokenItemState.Content] to ImmutableList<[TokenActionButtonConfig]>
*
* @property currentStateProvider current ui state provider
* @property clickIntents screen click intents
*
*/
@Suppress("UnusedPrivateMember")
internal class TokenActionsProvider(
private val currentStateProvider: Provider<WalletState>,
) {
internal class TokenActionsProvider(private val clickIntents: WalletClickIntents) {
@Suppress("UnusedPrivateMember")
fun provideActions(tokenId: String): ImmutableList<TokenActionButtonConfig> {
fun provideActions(cryptoCurrencyStatus: CryptoCurrencyStatus): ImmutableList<TokenActionButtonConfig> {
// TODO: [REDACTED_JIRA]
return mockTokenActionButtonConfig().toImmutableList()
return mockTokenActionButtonConfig(cryptoCurrencyStatus).toImmutableList()
}
private fun mockTokenActionButtonConfig(): List<TokenActionButtonConfig> {
private fun mockTokenActionButtonConfig(cryptoCurrencyStatus: CryptoCurrencyStatus): List<TokenActionButtonConfig> {
return listOf(
TokenActionButtonConfig(
text = "Send",
iconResId = R.drawable.ic_plus_24,
onClick = {},
onClick = { clickIntents.onMultiCurrencySendClick(cryptoCurrencyStatus) },
),
TokenActionButtonConfig(
text = "Buy",

View file

@ -8,8 +8,8 @@ import com.tangem.feature.wallet.presentation.wallet.state.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletManageButton
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletClickIntents
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
internal class WalletCryptoCurrencyActionsConverter(
private val currentStateProvider: Provider<WalletState>,
@ -26,7 +26,7 @@ internal class WalletCryptoCurrencyActionsConverter(
}
}
private fun List<TokenActionsState.ActionState>.mapToManageButtons(): ImmutableList<WalletManageButton> {
private fun List<TokenActionsState.ActionState>.mapToManageButtons(): PersistentList<WalletManageButton> {
return this
.mapNotNull { action ->
when (action) {
@ -40,11 +40,14 @@ internal class WalletCryptoCurrencyActionsConverter(
WalletManageButton.Sell(enabled = action.enabled, onClick = clickIntents::onSellClick)
}
is TokenActionsState.ActionState.Send -> {
WalletManageButton.Send(enabled = action.enabled, onClick = clickIntents::onSendClick)
WalletManageButton.Send(
enabled = action.enabled,
onClick = clickIntents::onSingleCurrencySendClick,
)
}
is TokenActionsState.ActionState.Swap -> null
}
}
.toImmutableList()
.toPersistentList()
}
}

View file

@ -0,0 +1,50 @@
package com.tangem.feature.wallet.presentation.wallet.state.factory
import com.tangem.common.Provider
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.wallet.presentation.wallet.state.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletCardState
import com.tangem.feature.wallet.presentation.wallet.state.factory.WalletDeleteStateConverter.DeleteWalletModel
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletsUpdateActionResolver
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Converter that responds on wallet deleting action. Returns [WalletState] without deleted wallet.
*
* @property currentStateProvider current state provider
*/
internal class WalletDeleteStateConverter(
private val currentStateProvider: Provider<WalletState>,
) : Converter<DeleteWalletModel, WalletState> {
override fun convert(value: DeleteWalletModel): WalletState {
return when (val state = currentStateProvider()) {
is WalletState.ContentState -> {
value.cacheState.copySealed(
walletsListConfig = state.walletsListConfig.copy(
selectedWalletIndex = value.action.selectedWalletIndex,
wallets = state.walletsListConfig.wallets.deleteWallet(id = value.action.deletedWalletId),
),
pullToRefreshConfig = value.cacheState.pullToRefreshConfig.copy(isRefreshing = false),
)
}
is WalletState.Initial -> state
}
}
private fun List<WalletCardState>.deleteWallet(id: UserWalletId): ImmutableList<WalletCardState> {
return this
.mapIndexedNotNull { index, currentWallet ->
if (currentWallet.id == id) return@mapIndexedNotNull null
getOrNull(index) ?: return@mapIndexedNotNull null
}
.toImmutableList()
}
data class DeleteWalletModel(
val cacheState: WalletState.ContentState,
val action: WalletsUpdateActionResolver.Action.DeleteWallet,
)
}

View file

@ -9,7 +9,6 @@ import com.tangem.domain.tokens.model.TokenList
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.wallet.presentation.wallet.state.WalletMultiCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.factory.WalletLoadedTokensListConverter.LoadedTokensListModel
import com.tangem.feature.wallet.presentation.wallet.utils.TokenListErrorConverter
import com.tangem.feature.wallet.presentation.wallet.utils.TokenListToWalletStateConverter
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletClickIntents
@ -27,11 +26,12 @@ import com.tangem.utils.converter.Converter
*/
internal class WalletLoadedTokensListConverter(
private val currentStateProvider: Provider<WalletState>,
private val tokenListErrorConverter: TokenListErrorConverter,
appCurrencyProvider: Provider<AppCurrency>,
cardTypeResolverProvider: Provider<CardTypesResolver>,
currentWalletProvider: Provider<UserWallet>,
clickIntents: WalletClickIntents,
) : Converter<LoadedTokensListModel, WalletState> {
) : Converter<Either<TokenListError, TokenList>, WalletState> {
private val tokenListStateConverter = TokenListToWalletStateConverter(
currentStateProvider = currentStateProvider,
@ -42,26 +42,10 @@ internal class WalletLoadedTokensListConverter(
clickIntents = clickIntents,
)
private val tokenListErrorStateConverter = TokenListErrorConverter(
currentStateProvider = currentStateProvider,
)
override fun convert(value: LoadedTokensListModel): WalletState {
return value.tokenListEither.fold(
ifLeft = tokenListErrorStateConverter::convert,
ifRight = {
tokenListStateConverter.convert(
value = TokenListToWalletStateConverter.TokensListModel(
tokenList = it,
isRefreshing = value.isRefreshing,
),
)
},
override fun convert(value: Either<TokenListError, TokenList>): WalletState {
return value.fold(
ifLeft = tokenListErrorConverter::convert,
ifRight = tokenListStateConverter::convert,
)
}
data class LoadedTokensListModel(
val tokenListEither: Either<TokenListError, TokenList>,
val isRefreshing: Boolean,
)
}

View file

@ -1,110 +1,78 @@
package com.tangem.feature.wallet.presentation.wallet.state.factory
import com.tangem.common.Provider
import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory
import com.tangem.feature.wallet.presentation.wallet.state.WalletMultiCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletSingleCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletCardState
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletManageButton
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletPullToRefreshConfig
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletTopBarConfig
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletsListConfig
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletClickIntents
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
internal class WalletLockedConverter(
private val currentStateProvider: Provider<WalletState>,
private val currentCardTypeResolverProvider: Provider<CardTypesResolver>,
private val currentWalletProvider: Provider<UserWallet>,
private val clickIntents: WalletClickIntents,
) : Converter<Unit, WalletState> {
override fun convert(value: Unit): WalletState {
return when (val state = currentStateProvider()) {
is WalletState.ContentState -> {
val cardTypeResolver = currentCardTypeResolverProvider()
if (cardTypeResolver.isMultiwalletAllowed()) {
state.toMultiCurrencyLockedState(cardTypeResolver)
} else {
state.toSingleCurrencyLockedState(cardTypeResolver)
}
}
is WalletState.Initial -> state
is WalletMultiCurrencyState.Content -> state.toMultiCurrencyLockedState()
is WalletSingleCurrencyState.Content -> state.toSingleCurrencyLockedState()
is WalletMultiCurrencyState.Locked,
is WalletSingleCurrencyState.Locked,
is WalletState.Initial,
-> state
}
}
private fun WalletState.ContentState.toMultiCurrencyLockedState(
cardTypeResolver: CardTypesResolver,
): WalletMultiCurrencyState.Locked {
private fun WalletMultiCurrencyState.Content.toMultiCurrencyLockedState(): WalletState {
return WalletMultiCurrencyState.Locked(
onBackClick = onBackClick,
topBarConfig = createTopBarConfig(),
walletsListConfig = createWalletsListConfig(cardTypeResolver),
pullToRefreshConfig = pullToRefreshConfig,
topBarConfig = topBarConfig.updateCallback(),
walletsListConfig = walletsListConfig,
pullToRefreshConfig = pullToRefreshConfig.stopRefreshing(),
onUnlockWalletsNotificationClick = clickIntents::onUnlockWalletNotificationClick,
onUnlockClick = clickIntents::onUnlockWalletClick,
onScanClick = clickIntents::onScanCardClick,
onScanClick = clickIntents::onScanToUnlockWalletClick,
)
}
private fun WalletState.ContentState.toSingleCurrencyLockedState(
cardTypeResolver: CardTypesResolver,
): WalletSingleCurrencyState.Locked {
private fun WalletSingleCurrencyState.Content.toSingleCurrencyLockedState(): WalletState {
return WalletSingleCurrencyState.Locked(
onBackClick = onBackClick,
topBarConfig = createTopBarConfig(),
walletsListConfig = createWalletsListConfig(cardTypeResolver),
pullToRefreshConfig = pullToRefreshConfig,
buttons = createButtons(),
topBarConfig = topBarConfig.updateCallback(),
walletsListConfig = walletsListConfig,
pullToRefreshConfig = pullToRefreshConfig.stopRefreshing(),
buttons = buttons.disableButtons(),
onUnlockWalletsNotificationClick = clickIntents::onUnlockWalletNotificationClick,
onUnlockClick = clickIntents::onUnlockWalletClick,
onScanClick = clickIntents::onScanCardClick,
onScanClick = clickIntents::onScanToUnlockWalletClick,
onExploreClick = clickIntents::onExploreClick,
)
}
private fun WalletState.ContentState.createTopBarConfig(): WalletTopBarConfig {
return topBarConfig.copy(onMoreClick = clickIntents::onUnlockWalletNotificationClick)
private fun WalletTopBarConfig.updateCallback(): WalletTopBarConfig {
return copy(onMoreClick = clickIntents::onUnlockWalletNotificationClick)
}
private fun WalletState.ContentState.createWalletsListConfig(
cardTypeResolver: CardTypesResolver,
): WalletsListConfig {
return walletsListConfig.copy(
wallets = walletsListConfig.wallets
.map { walletCardState ->
WalletCardState.LockedContent(
id = walletCardState.id,
title = walletCardState.title,
additionalInfo = if (cardTypeResolver.isMultiwalletAllowed()) {
WalletAdditionalInfoFactory.resolve(
cardTypesResolver = cardTypeResolver,
wallet = currentWalletProvider(),
)
} else {
null
},
imageResId = walletCardState.imageResId,
onRenameClick = walletCardState.onRenameClick,
onDeleteClick = walletCardState.onDeleteClick,
)
private fun WalletPullToRefreshConfig.stopRefreshing(): WalletPullToRefreshConfig {
return copy(isRefreshing = false)
}
private fun PersistentList<WalletManageButton>.disableButtons(): PersistentList<WalletManageButton> {
return this
.map { button ->
when (button) {
is WalletManageButton.Buy -> button.copy(enabled = false)
is WalletManageButton.Sell -> button.copy(enabled = false)
is WalletManageButton.Send -> button.copy(enabled = false)
is WalletManageButton.Swap -> button.copy(enabled = false)
is WalletManageButton.Receive -> button
}
.toImmutableList(),
)
}
private fun createButtons(): ImmutableList<WalletManageButton> {
return persistentListOf(
WalletManageButton.Buy(enabled = false, onClick = {}),
WalletManageButton.Send(enabled = false, onClick = {}),
WalletManageButton.Receive(onClick = {}),
WalletManageButton.Sell(enabled = false, onClick = {}),
)
}
.toPersistentList()
}
}

View file

@ -1,124 +1,115 @@
package com.tangem.feature.wallet.presentation.wallet.state.factory
import com.tangem.common.Provider
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.domain.common.CardTypesResolver
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.feature.wallet.presentation.wallet.state.WalletMultiCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletSingleCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.components.*
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletTokensListState.TokensListItemState
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletManageButton
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletPullToRefreshConfig
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletTokensListState
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.update
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.mutate
internal class WalletRefreshStateConverter(
private val currentStateProvider: Provider<WalletState>,
private val currentCardTypeResolverProvider: Provider<CardTypesResolver>,
private val clickIntents: WalletClickIntents,
) : Converter<Unit, WalletState> {
) : Converter<Boolean, WalletState> {
override fun convert(value: Unit): WalletState {
return when (val state = currentStateProvider()) {
is WalletMultiCurrencyState.Content -> state.getRefreshState()
is WalletSingleCurrencyState.Content -> state.getRefreshState()
else -> state
}
}
override fun convert(value: Boolean): WalletState {
val state = currentStateProvider()
val contentState = state as? WalletState.ContentState ?: return state
private fun WalletMultiCurrencyState.Content.getRefreshState(): WalletMultiCurrencyState.Content {
return copy(
walletsListConfig = createWalletsListConfig(),
pullToRefreshConfig = createPullToRefreshConfig(),
tokensListState = createTokenListState(),
)
}
private fun WalletSingleCurrencyState.Content.getRefreshState(): WalletSingleCurrencyState.Content {
return copy(
walletsListConfig = createWalletsListConfig(),
pullToRefreshConfig = createPullToRefreshConfig(),
buttons = buttons.mapToDisabledButton(),
txHistoryState = createTxHistoryState(),
marketPriceBlockState = MarketPriceBlockState.Loading(currencyName = marketPriceBlockState.currencyName),
)
}
private fun WalletState.ContentState.createWalletsListConfig(): WalletsListConfig {
val selectedWallet = walletsListConfig.wallets[walletsListConfig.selectedWalletIndex]
val additionalInfo = if (currentCardTypeResolverProvider().isMultiwalletAllowed()) {
selectedWallet.additionalInfo
return if (value) {
contentState.getRefreshingState()
} else {
null
contentState.getRefreshedState()
}
}
return walletsListConfig.copy(
wallets = walletsListConfig.wallets.toPersistentList().set(
index = walletsListConfig.selectedWalletIndex,
element = WalletCardState.Loading(
id = selectedWallet.id,
title = selectedWallet.title,
additionalInfo = additionalInfo,
imageResId = selectedWallet.imageResId,
onRenameClick = selectedWallet.onRenameClick,
onDeleteClick = selectedWallet.onDeleteClick,
),
),
private fun WalletState.ContentState.getRefreshingState(): WalletState {
return when (this) {
is WalletMultiCurrencyState.Content -> getRefreshingState()
is WalletSingleCurrencyState.Content -> getRefreshingState()
is WalletMultiCurrencyState.Locked,
is WalletSingleCurrencyState.Locked,
-> this
}
}
private fun WalletState.ContentState.getRefreshedState(): WalletState {
return when (this) {
is WalletMultiCurrencyState.Content -> getRefreshedState()
is WalletSingleCurrencyState.Content -> getRefreshedState()
is WalletMultiCurrencyState.Locked,
is WalletSingleCurrencyState.Locked,
-> this
}
}
private fun WalletMultiCurrencyState.Content.getRefreshingState(): WalletMultiCurrencyState {
return copy(
pullToRefreshConfig = updatePullToRefreshConfig(isRefreshing = true),
tokensListState = updateTokenListState(isRefreshing = true),
)
}
private fun WalletState.ContentState.createPullToRefreshConfig(): WalletPullToRefreshConfig {
return pullToRefreshConfig.copy(isRefreshing = true)
private fun WalletSingleCurrencyState.Content.getRefreshingState(): WalletSingleCurrencyState {
return copy(
pullToRefreshConfig = updatePullToRefreshConfig(isRefreshing = true),
buttons = updateButtons(isRefreshing = true),
)
}
private fun WalletMultiCurrencyState.Content.createTokenListState(): WalletTokensListState {
return when (tokensListState) {
private fun WalletMultiCurrencyState.Content.getRefreshedState(): WalletMultiCurrencyState {
return copy(
pullToRefreshConfig = updatePullToRefreshConfig(isRefreshing = false),
tokensListState = updateTokenListState(isRefreshing = false),
)
}
private fun WalletSingleCurrencyState.Content.getRefreshedState(): WalletSingleCurrencyState {
return copy(
pullToRefreshConfig = updatePullToRefreshConfig(isRefreshing = false),
buttons = updateButtons(isRefreshing = false),
)
}
private fun WalletMultiCurrencyState.updateTokenListState(isRefreshing: Boolean): WalletTokensListState {
return when (val listState = tokensListState) {
is WalletTokensListState.Content -> {
WalletTokensListState.Loading(
items = tokensListState.items
.filterIsInstance<TokensListItemState.Token>()
.mapToLoadingTokenState(),
)
when (listState.organizeTokensButton) {
is WalletTokensListState.OrganizeTokensButtonState.Hidden -> listState
is WalletTokensListState.OrganizeTokensButtonState.Visible -> listState.copy(
organizeTokensButton = listState.organizeTokensButton.copy(
isEnabled = !isRefreshing,
),
)
}
}
is WalletTokensListState.Empty -> WalletTokensListState.Loading()
is WalletTokensListState.Loading,
is WalletTokensListState.Locked,
-> tokensListState
is WalletTokensListState.Loading,
is WalletTokensListState.Empty,
-> listState
}
}
private fun List<TokensListItemState.Token>.mapToLoadingTokenState(): ImmutableList<TokensListItemState.Token> {
return this
.map { TokensListItemState.Token(state = TokenItemState.Loading(id = it.state.id)) }
.toImmutableList()
}
private fun WalletSingleCurrencyState.updateButtons(isRefreshing: Boolean): PersistentList<WalletManageButton> {
val isButtonsEnabled = !isRefreshing
private fun ImmutableList<WalletManageButton>.mapToDisabledButton(): ImmutableList<WalletManageButton> {
return this
.mapNotNull { button ->
return buttons.mutate {
it.mapNotNull { button ->
when (button) {
is WalletManageButton.Buy -> button.copy(enabled = false)
is WalletManageButton.Send -> button.copy(enabled = false)
is WalletManageButton.Buy -> button.copy(enabled = isButtonsEnabled)
is WalletManageButton.Send -> button.copy(enabled = isButtonsEnabled)
is WalletManageButton.Sell -> button.copy(enabled = isButtonsEnabled)
is WalletManageButton.Receive -> button
is WalletManageButton.Sell -> button.copy(enabled = false)
is WalletManageButton.Swap -> null
}
}
.toImmutableList()
}
}
private fun WalletSingleCurrencyState.Content.createTxHistoryState(): TxHistoryState {
if (txHistoryState is TxHistoryState.Content) {
txHistoryState.contentItems.update {
TxHistoryState.getDefaultLoadingTransactions(onExploreClick = clickIntents::onExploreClick)
}
}
return txHistoryState
private fun WalletState.ContentState.updatePullToRefreshConfig(isRefreshing: Boolean): WalletPullToRefreshConfig {
return pullToRefreshConfig.copy(isRefreshing = isRefreshing)
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.feature.wallet.presentation.wallet.state.factory
import com.tangem.common.Provider
import com.tangem.feature.wallet.presentation.wallet.state.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletsListConfig
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
internal class WalletRenameStateConverter(
private val currentStateProvider: Provider<WalletState>,
) : Converter<String, WalletState> {
override fun convert(value: String): WalletState {
return when (val state = currentStateProvider()) {
is WalletState.ContentState -> {
state.copySealed(
walletsListConfig = state.walletsListConfig.renameSelectedWallet(name = value),
)
}
is WalletState.Initial -> state
}
}
private fun WalletsListConfig.renameSelectedWallet(name: String): WalletsListConfig {
return copy(
wallets = wallets
.mapIndexed { index, walletCard ->
if (index == selectedWalletIndex) walletCard.copySealed(title = name) else walletCard
}
.toImmutableList(),
)
}
}

View file

@ -11,11 +11,12 @@ import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory
import com.tangem.feature.wallet.presentation.wallet.state.WalletMultiCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletSingleCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletCardState
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletsListConfig
import com.tangem.feature.wallet.presentation.wallet.state.factory.WalletSingleCurrencyLoadedBalanceConverter.SingleCurrencyLoadedBalanceModel
import com.tangem.feature.wallet.presentation.wallet.utils.CurrencyStatusErrorConverter
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
@ -25,34 +26,32 @@ internal class WalletSingleCurrencyLoadedBalanceConverter(
private val cardTypeResolverProvider: Provider<CardTypesResolver>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val currentWalletProvider: Provider<UserWallet>,
) : Converter<SingleCurrencyLoadedBalanceModel, WalletSingleCurrencyState.Content> {
private val currencyStatusErrorConverter: CurrencyStatusErrorConverter,
) : Converter<Either<CurrencyStatusError, CryptoCurrencyStatus>, WalletState> {
override fun convert(value: SingleCurrencyLoadedBalanceModel): WalletSingleCurrencyState.Content {
return value.cryptoCurrencyEither.fold(
ifLeft = { convertError() },
ifRight = { convertContent(it, value.isRefreshing) },
override fun convert(value: Either<CurrencyStatusError, CryptoCurrencyStatus>): WalletState {
return value.fold(
ifLeft = currencyStatusErrorConverter::convert,
ifRight = ::convertContent,
)
}
private fun convertError(): WalletSingleCurrencyState.Content {
return requireNotNull(currentStateProvider() as? WalletSingleCurrencyState.Content)
}
private fun convertContent(status: CryptoCurrencyStatus): WalletState {
return when (val state = currentStateProvider()) {
is WalletSingleCurrencyState.Content -> {
val currencyName = state.marketPriceBlockState.currencyName
private fun convertContent(
status: CryptoCurrencyStatus,
isRefreshing: Boolean,
): WalletSingleCurrencyState.Content {
val state = requireNotNull(currentStateProvider() as? WalletSingleCurrencyState.Content)
val currencyName = state.marketPriceBlockState.currencyName
return state.copy(
walletsListConfig = getUpdatedSelectedWallet(status = status.value, state = state),
pullToRefreshConfig = if (isRefreshing) {
state.pullToRefreshConfig.copy(isRefreshing = status.value is CryptoCurrencyStatus.Loading)
} else {
state.pullToRefreshConfig
},
marketPriceBlockState = getMarketPriceState(status = status.value, currencyName = currencyName),
)
state.copy(
walletsListConfig = getUpdatedSelectedWallet(status = status.value, state = state),
marketPriceBlockState = getMarketPriceState(status = status.value, currencyName = currencyName),
)
}
is WalletMultiCurrencyState.Content,
is WalletMultiCurrencyState.Locked,
is WalletSingleCurrencyState.Locked,
is WalletState.Initial,
-> state
}
}
private fun getMarketPriceState(status: CryptoCurrencyStatus.Status, currencyName: String): MarketPriceBlockState {
@ -168,9 +167,4 @@ internal class WalletSingleCurrencyLoadedBalanceConverter(
fiatCurrencySymbol = appCurrency.symbol,
)
}
data class SingleCurrencyLoadedBalanceModel(
val cryptoCurrencyEither: Either<CurrencyStatusError, CryptoCurrencyStatus>,
val isRefreshing: Boolean,
)
}

View file

@ -1,8 +1,10 @@
package com.tangem.feature.wallet.presentation.wallet.state.factory
import androidx.annotation.DrawableRes
import com.tangem.common.Provider
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory
@ -14,7 +16,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.components.*
import com.tangem.feature.wallet.presentation.wallet.state.factory.WalletSkeletonStateConverter.SkeletonModel
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletClickIntents
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
@ -33,12 +35,12 @@ internal class WalletSkeletonStateConverter(
) : Converter<SkeletonModel, WalletState.ContentState> {
override fun convert(value: SkeletonModel): WalletState.ContentState {
val cardTypeResolver = value.wallets[value.selectedWalletIndex].scanResponse.cardTypesResolver
val selectedWallet = value.wallets[value.selectedWalletIndex]
return if (cardTypeResolver.isMultiwalletAllowed()) {
return if (selectedWallet.isMultiCurrency) {
createMultiCurrencyState(value = value)
} else {
createSingleCurrencyState(value = value, currencyName = cardTypeResolver.getBlockchain().currency)
createSingleCurrencyState(value = value, currencyName = selectedWallet.getPrimaryCurrencyName())
}
}
@ -74,6 +76,10 @@ internal class WalletSkeletonStateConverter(
)
}
private fun UserWallet.getPrimaryCurrencyName(): String {
return scanResponse.cardTypesResolver.getBlockchain().currency
}
private fun createTopBarConfig(): WalletTopBarConfig {
return WalletTopBarConfig(
onScanCardClick = clickIntents::onScanCardClick,
@ -84,51 +90,68 @@ internal class WalletSkeletonStateConverter(
private fun createWalletsListConfig(value: SkeletonModel): WalletsListConfig {
return WalletsListConfig(
selectedWalletIndex = value.selectedWalletIndex,
wallets = value.wallets.map(::createWalletState).toImmutableList(),
wallets = value.wallets.mapIndexed(::createWalletCardState).toImmutableList(),
onWalletChange = clickIntents::onWalletChange,
)
}
private fun createWalletState(wallet: UserWallet): WalletCardState {
val state = currentStateProvider()
// If it isn't first initialization (example, when user unlocks wallet)
return if (state is WalletState.ContentState) {
val initializedWallet = state.walletsListConfig.wallets.first { it.id == wallet.walletId }
// If wallet is initialized, return it, otherwise return loading state
if (initializedWallet !is WalletCardState.Loading) {
initializedWallet.copySealed(title = wallet.name)
} else {
createWalletLoadingState(wallet)
}
} else {
createWalletLoadingState(wallet)
}
/**
* Create wallet card state by [index] and [wallet].
* If current wallet card state is initialized, then method returns it.
* Otherwise, returns loading wallet card state.
*/
private fun createWalletCardState(index: Int, wallet: UserWallet): WalletCardState {
return currentStateProvider().getInitializedWalletCardState(index) ?: wallet.mapToWalletCardState()
}
private fun createWalletLoadingState(wallet: UserWallet): WalletCardState {
val cardTypeResolver = wallet.scanResponse.cardTypesResolver
private fun WalletState.getInitializedWalletCardState(index: Int): WalletCardState? {
return (this as? WalletState.ContentState)?.walletsListConfig?.wallets?.getOrNull(index)
}
return WalletCardState.Loading(
id = wallet.walletId,
title = wallet.name,
additionalInfo = if (cardTypeResolver.isMultiwalletAllowed()) {
WalletAdditionalInfoFactory.resolve(cardTypesResolver = cardTypeResolver, wallet = wallet)
} else {
null
},
imageResId = WalletImageResolver.resolve(cardTypesResolver = cardTypeResolver),
private fun UserWallet.mapToWalletCardState(): WalletCardState {
return if (isLocked) mapToLockedWalletCardState() else mapToLoadingWalletCardState()
}
private fun UserWallet.mapToLockedWalletCardState(): WalletCardState {
return WalletCardState.LockedContent(
id = walletId,
title = name,
additionalInfo = createAdditionalInfo(),
imageResId = createImageResId(),
onRenameClick = clickIntents::onRenameClick,
onDeleteClick = clickIntents::onDeleteClick,
)
}
private fun UserWallet.mapToLoadingWalletCardState(): WalletCardState {
return WalletCardState.Loading(
id = walletId,
title = name,
additionalInfo = createAdditionalInfo(),
imageResId = createImageResId(),
onRenameClick = clickIntents::onRenameClick,
onDeleteClick = clickIntents::onDeleteClick,
)
}
private fun UserWallet.createAdditionalInfo(): TextReference? {
return if (isMultiCurrency) {
WalletAdditionalInfoFactory.resolve(cardTypesResolver = scanResponse.cardTypesResolver, wallet = this)
} else {
null
}
}
@DrawableRes
private fun UserWallet.createImageResId(): Int? {
return WalletImageResolver.resolve(cardTypesResolver = scanResponse.cardTypesResolver)
}
private fun createPullToRefreshConfig(): WalletPullToRefreshConfig {
return WalletPullToRefreshConfig(isRefreshing = false, onRefresh = clickIntents::onRefreshSwipe)
}
private fun createButtons(): ImmutableList<WalletManageButton> {
private fun createButtons(): PersistentList<WalletManageButton> {
return persistentListOf(
WalletManageButton.Buy(enabled = false, onClick = {}),
WalletManageButton.Send(enabled = false, onClick = {}),

View file

@ -22,7 +22,10 @@ import com.tangem.feature.wallet.presentation.wallet.state.components.WalletBott
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletNotification
import com.tangem.feature.wallet.presentation.wallet.state.factory.txhistory.WalletLoadedTxHistoryConverter
import com.tangem.feature.wallet.presentation.wallet.state.factory.txhistory.WalletLoadingTxHistoryConverter
import com.tangem.feature.wallet.presentation.wallet.utils.CurrencyStatusErrorConverter
import com.tangem.feature.wallet.presentation.wallet.utils.TokenListErrorConverter
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletsUpdateActionResolver
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.Flow
@ -42,12 +45,26 @@ internal class WalletStateFactory(
private val clickIntents: WalletClickIntents,
) {
private val tokenActionsProvider by lazy { TokenActionsProvider(currentStateProvider = currentStateProvider) }
private val tokenActionsProvider by lazy { TokenActionsProvider(clickIntents) }
private val skeletonConverter by lazy { WalletSkeletonStateConverter(currentStateProvider, clickIntents) }
private val walletsUnlockStateConverter by lazy { WalletsUnlockStateConverter(currentStateProvider, clickIntents) }
private val walletRenameStateConverter by lazy { WalletRenameStateConverter(currentStateProvider) }
private val walletDeleteStateConverter by lazy { WalletDeleteStateConverter(currentStateProvider) }
private val tokenListErrorConverter by lazy {
TokenListErrorConverter(currentStateProvider)
}
private val currencyStatusErrorConverter by lazy {
CurrencyStatusErrorConverter(currentStateProvider)
}
private val loadedTokensListConverter by lazy {
WalletLoadedTokensListConverter(
currentStateProvider = currentStateProvider,
tokenListErrorConverter = tokenListErrorConverter,
cardTypeResolverProvider = currentCardTypeResolverProvider,
currentWalletProvider = currentWalletProvider,
appCurrencyProvider = appCurrencyProvider,
@ -76,24 +93,19 @@ internal class WalletStateFactory(
cardTypeResolverProvider = currentCardTypeResolverProvider,
appCurrencyProvider = appCurrencyProvider,
currentWalletProvider = currentWalletProvider,
currencyStatusErrorConverter = currencyStatusErrorConverter,
)
}
private val lockedConverter by lazy {
WalletLockedConverter(
currentStateProvider = currentStateProvider,
currentCardTypeResolverProvider = currentCardTypeResolverProvider,
currentWalletProvider = currentWalletProvider,
clickIntents = clickIntents,
)
}
private val refreshStateConverter by lazy {
WalletRefreshStateConverter(
currentStateProvider = currentStateProvider,
currentCardTypeResolverProvider = currentCardTypeResolverProvider,
clickIntents = clickIntents,
)
WalletRefreshStateConverter(currentStateProvider)
}
private val cryptoCurrencyActionsConverter by lazy {
@ -114,15 +126,29 @@ internal class WalletStateFactory(
)
}
fun getStateByTokensList(tokenListEither: Either<TokenListError, TokenList>, isRefreshing: Boolean): WalletState {
return loadedTokensListConverter.convert(
value = WalletLoadedTokensListConverter.LoadedTokensListModel(
tokenListEither = tokenListEither,
isRefreshing = isRefreshing,
),
fun getStateWithUpdatedWalletName(name: String): WalletState = walletRenameStateConverter.convert(value = name)
fun getUnlockedState(action: WalletsUpdateActionResolver.Action.UnlockWallet): WalletState {
return walletsUnlockStateConverter.convert(value = action)
}
fun getStateWithoutDeletedWallet(
cacheState: WalletState.ContentState,
action: WalletsUpdateActionResolver.Action.DeleteWallet,
): WalletState {
return walletDeleteStateConverter.convert(
value = WalletDeleteStateConverter.DeleteWalletModel(cacheState = cacheState, action = action),
)
}
fun getStateByTokensList(maybeTokenList: Either<TokenListError, TokenList>): WalletState {
return loadedTokensListConverter.convert(maybeTokenList)
}
fun getStateByTokenListError(error: TokenListError): WalletState {
return tokenListErrorConverter.convert(error)
}
fun getStateByNotifications(notifications: ImmutableList<WalletNotification>): WalletState {
return when (val state = currentStateProvider()) {
is WalletMultiCurrencyState.Content -> state.copy(notifications = notifications)
@ -131,7 +157,9 @@ internal class WalletStateFactory(
}
}
fun getStateAfterContentRefreshing(): WalletState = refreshStateConverter.convert(Unit)
fun getRefreshingState(): WalletState = refreshStateConverter.convert(value = true)
fun getRefreshedState(): WalletState = refreshStateConverter.convert(value = false)
fun getStateWithOpenWalletBottomSheet(content: WalletBottomSheetConfig.BottomSheetContentConfig): WalletState {
return when (val state = currentStateProvider() as WalletState.ContentState) {
@ -157,6 +185,7 @@ internal class WalletStateFactory(
isBottomSheetShow = true,
onBottomSheetDismiss = clickIntents::onDismissBottomSheet,
)
else -> state
}
}
@ -170,15 +199,16 @@ internal class WalletStateFactory(
bottomSheetConfig = state.bottomSheetConfig?.copy(isShow = false),
)
is WalletSingleCurrencyState.Locked -> state.copy(isBottomSheetShow = false)
else -> state
}
}
fun getStateWithTokenActionBottomSheet(tokenId: String): WalletState {
fun getStateWithTokenActionBottomSheet(currencyStatus: CryptoCurrencyStatus): WalletState {
return when (val state = currentStateProvider() as WalletState.ContentState) {
is WalletMultiCurrencyState.Content -> state.copy(
tokenActionsBottomSheet = ActionsBottomSheetConfig(
isShow = true,
actions = tokenActionsProvider.provideActions(tokenId = tokenId),
actions = tokenActionsProvider.provideActions(currencyStatus),
onDismissRequest = clickIntents::onDismissActionsBottomSheet,
),
)
@ -199,18 +229,16 @@ internal class WalletStateFactory(
fun getLockedState(): WalletState = lockedConverter.convert(Unit)
fun getSingleCurrencyLoadedBalanceState(
cryptoCurrencyEither: Either<CurrencyStatusError, CryptoCurrencyStatus>,
isRefreshing: Boolean,
maybeCryptoCurrencyStatus: Either<CurrencyStatusError, CryptoCurrencyStatus>,
): WalletState {
return singleCurrencyLoadedBalanceConverter.convert(
value = WalletSingleCurrencyLoadedBalanceConverter.SingleCurrencyLoadedBalanceModel(
cryptoCurrencyEither = cryptoCurrencyEither,
isRefreshing = isRefreshing,
),
)
return singleCurrencyLoadedBalanceConverter.convert(maybeCryptoCurrencyStatus)
}
fun getSingleCurrencyManageButtonsState(actions: List<TokenActionsState.ActionState>): WalletState {
return cryptoCurrencyActionsConverter.convert(value = actions)
}
fun getStateByCurrencyStatusError(error: CurrencyStatusError): WalletState {
return currencyStatusErrorConverter.convert(error)
}
}

View file

@ -0,0 +1,142 @@
package com.tangem.feature.wallet.presentation.wallet.state.factory
import com.tangem.common.Provider
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory
import com.tangem.feature.wallet.presentation.wallet.domain.WalletImageResolver
import com.tangem.feature.wallet.presentation.wallet.state.WalletMultiCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletSingleCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.components.*
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletClickIntents
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletsUpdateActionResolver.Action.UnlockWallet as UnlockWalletAction
/**
* Converter that responds on wallets unlocking action. Returns [WalletState] with unlocked wallets.
*
* @property currentStateProvider current ui state provider
* @property clickIntents screen click intents
*
[REDACTED_AUTHOR]
*/
internal class WalletsUnlockStateConverter(
private val currentStateProvider: Provider<WalletState>,
private val clickIntents: WalletClickIntents,
) : Converter<UnlockWalletAction, WalletState> {
override fun convert(value: UnlockWalletAction): WalletState {
return when (val state = currentStateProvider()) {
is WalletMultiCurrencyState.Locked -> state.toMultiCurrencyContentState(value)
is WalletSingleCurrencyState.Locked -> state.toSingleCurrencyContentState(value)
is WalletState.Initial,
is WalletMultiCurrencyState.Content,
is WalletSingleCurrencyState.Content,
-> state
}
}
private fun WalletMultiCurrencyState.Locked.toMultiCurrencyContentState(action: UnlockWalletAction): WalletState {
return WalletMultiCurrencyState.Content(
onBackClick = onBackClick,
topBarConfig = topBarConfig.updateCallback(),
walletsListConfig = walletsListConfig.unlockWallets(action),
pullToRefreshConfig = pullToRefreshConfig.stopRefreshing(),
tokensListState = WalletTokensListState.Loading(),
notifications = persistentListOf(),
bottomSheetConfig = null,
tokenActionsBottomSheet = null,
onManageTokensClick = clickIntents::onManageTokensClick,
)
}
private fun WalletSingleCurrencyState.Locked.toSingleCurrencyContentState(action: UnlockWalletAction): WalletState {
return WalletSingleCurrencyState.Content(
onBackClick = onBackClick,
topBarConfig = topBarConfig.updateCallback(),
walletsListConfig = walletsListConfig.unlockWallets(action),
pullToRefreshConfig = pullToRefreshConfig.stopRefreshing(),
notifications = persistentListOf(),
bottomSheetConfig = null,
buttons = buttons,
marketPriceBlockState = MarketPriceBlockState.Loading(
currencyName = action.selectedWallet.getPrimaryCurrencyName(),
),
txHistoryState = TxHistoryState.Content(
contentItems = MutableStateFlow(
value = TxHistoryState.getDefaultLoadingTransactions(clickIntents::onExploreClick),
),
),
)
}
private fun WalletTopBarConfig.updateCallback(): WalletTopBarConfig {
return copy(onMoreClick = clickIntents::onDetailsClick)
}
private fun WalletsListConfig.unlockWallets(action: UnlockWalletAction): WalletsListConfig {
return this.copy(
selectedWalletIndex = action.selectedWalletIndex,
wallets = wallets.unlockWallets(action),
)
}
private fun List<WalletCardState>.unlockWallets(action: UnlockWalletAction): ImmutableList<WalletCardState> {
return this
.map { prevWallet ->
if (prevWallet is WalletCardState.LockedContent && action.isUnlockedWallet(prevWallet.id)) {
prevWallet.mapToLoadingWalletCardState(
userWallet = action.getUnlockWallet(prevWallet.id),
)
} else {
prevWallet
}
}
.toImmutableList()
}
private fun UnlockWalletAction.isUnlockedWallet(walletId: UserWalletId): Boolean {
return unlockedWallets.any { it.walletId == walletId }
}
private fun UnlockWalletAction.getUnlockWallet(walletId: UserWalletId): UserWallet {
return unlockedWallets.firstOrNull { it.walletId == walletId }
?: error("Unlocked wallet with id $walletId not found")
}
private fun WalletCardState.mapToLoadingWalletCardState(userWallet: UserWallet): WalletCardState {
return WalletCardState.Loading(
id = id,
title = title,
additionalInfo = userWallet.createAdditionalInfo(),
imageResId = WalletImageResolver.resolve(cardTypesResolver = userWallet.scanResponse.cardTypesResolver),
onRenameClick = onRenameClick,
onDeleteClick = onDeleteClick,
)
}
private fun UserWallet.createAdditionalInfo(): TextReference? {
return if (isMultiCurrency) {
WalletAdditionalInfoFactory.resolve(cardTypesResolver = scanResponse.cardTypesResolver, wallet = this)
} else {
null
}
}
private fun WalletPullToRefreshConfig.stopRefreshing(): WalletPullToRefreshConfig {
return copy(isRefreshing = false)
}
private fun UserWallet.getPrimaryCurrencyName(): String {
return scanResponse.cardTypesResolver.getBlockchain().currency
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.domain.txhistory.models.TxHistoryListError
import com.tangem.feature.wallet.presentation.wallet.state.WalletMultiCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletSingleCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletState
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletClickIntents
@ -41,18 +42,34 @@ internal class WalletLoadedTxHistoryConverter(
}
private fun convertError(error: TxHistoryListError): WalletState {
return requireNotNull(currentStateProvider() as? WalletSingleCurrencyState.Content).copy(
txHistoryState = when (error) {
is TxHistoryListError.DataError -> {
TxHistoryState.Error(onReloadClick = clickIntents::onReloadClick)
}
},
)
return when (val state = currentStateProvider()) {
is WalletSingleCurrencyState.Content -> {
state.copy(
txHistoryState = when (error) {
is TxHistoryListError.DataError -> {
TxHistoryState.Error(onReloadClick = clickIntents::onReloadClick)
}
},
)
}
is WalletMultiCurrencyState.Content,
is WalletMultiCurrencyState.Locked,
is WalletSingleCurrencyState.Locked,
is WalletState.Initial,
-> state
}
}
private fun convert(items: Flow<PagingData<TxHistoryItem>>): WalletState {
return requireNotNull(currentStateProvider() as? WalletSingleCurrencyState.Content).copy(
txHistoryState = walletTxHistoryItemFlowConverter.convert(value = items),
)
return when (val state = currentStateProvider()) {
is WalletSingleCurrencyState.Content -> {
state.copy(txHistoryState = walletTxHistoryItemFlowConverter.convert(value = items))
}
is WalletMultiCurrencyState.Content,
is WalletMultiCurrencyState.Locked,
is WalletSingleCurrencyState.Locked,
is WalletState.Initial,
-> state
}
}
}

View file

@ -51,11 +51,11 @@ internal class WalletLoadingTxHistoryConverter(
}
}
private fun convert(value: Int): WalletSingleCurrencyState.Content {
val state = requireNotNull(currentStateProvider() as? WalletSingleCurrencyState.Content)
val txHistoryContent = requireNotNull(state.txHistoryState as? Content)
private fun convert(value: Int): WalletState {
val state = currentStateProvider()
val txHistoryContent = (state as? WalletSingleCurrencyState.Content)?.txHistoryState as? Content
txHistoryContent.contentItems.update {
txHistoryContent?.contentItems?.update {
PagingData.from(
data = listOf(TxHistoryItemState.Title(onExploreClick = clickIntents::onExploreClick)) +
MutableList(

View file

@ -1,15 +1,15 @@
package com.tangem.feature.wallet.presentation.wallet.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@ -26,10 +26,11 @@ import com.tangem.feature.wallet.presentation.wallet.state.WalletMultiCurrencySt
import com.tangem.feature.wallet.presentation.wallet.state.WalletSingleCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletTokensListState
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletTokensListState.OrganizeTokensButtonState
import com.tangem.feature.wallet.presentation.wallet.ui.components.TokenActionsBottomSheet
import com.tangem.feature.wallet.presentation.wallet.ui.components.WalletsList
import com.tangem.feature.wallet.presentation.wallet.ui.components.common.*
import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.organizeButton
import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.organizeTokensButton
import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.controlButtons
import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.marketPriceBlock
import com.tangem.feature.wallet.presentation.wallet.ui.utils.changeWalletAnimator
@ -110,9 +111,15 @@ private fun WalletContent(state: WalletState.ContentState) {
contentItems(state = state, txHistoryItems = txHistoryItems, modifier = movableItemModifier)
if (state is WalletMultiCurrencyState) {
val tokensListState = state.tokensListState
if (tokensListState is WalletTokensListState.ContentState) {
organizeButton(onClick = tokensListState.onOrganizeTokensClick, modifier = itemModifier)
val contentTokenListState = state.tokensListState as? WalletTokensListState.ContentState
val organizeTokensButton = contentTokenListState?.organizeTokensButton
if (organizeTokensButton is OrganizeTokensButtonState.Visible) {
organizeTokensButton(
modifier = itemModifier,
isEnabled = organizeTokensButton.isEnabled,
onClick = organizeTokensButton.onClick,
)
}
}
}

View file

@ -65,7 +65,7 @@ private fun ActionsBottomSheetContent_Dark(
@PreviewParameter(ActionsBottomSheetContentConfigProvider::class)
config: ActionsBottomSheetConfig,
) {
TangemTheme(isDark = false) {
TangemTheme(isDark = true) {
// Use preview of content because ModalBottomSheet isn't supported in Preview mode
ActionsBottomSheetContent(actions = config.actions)
}

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -163,22 +164,14 @@ private fun CardContainer(
var isRenameWalletDialogVisible by rememberSaveable { mutableStateOf(value = false) }
DropdownMenu(
expanded = isMenuVisible,
ManageWalletContextMenu(
isMenuVisible = isMenuVisible,
pressOffset = pressOffset,
itemHeight = itemHeight,
onDismissRequest = { isMenuVisible = false },
modifier = Modifier.background(color = TangemTheme.colors.background.secondary),
offset = pressOffset.copy(y = pressOffset.y - itemHeight),
) {
MenuItem(
textResId = R.string.common_rename,
imageVector = Icons.Outlined.Edit,
onClick = {
isMenuVisible = false
isRenameWalletDialogVisible = true
},
)
MenuItem(textResId = R.string.common_delete, imageVector = Icons.Outlined.Delete, onClick = onDeleteClick)
}
onShowRenameWalletDialogClick = { isRenameWalletDialogVisible = true },
onDeleteClick = onDeleteClick,
)
if (isRenameWalletDialogVisible) {
RenameWalletDialogContent(
@ -192,6 +185,41 @@ private fun CardContainer(
}
}
@Suppress("LongParameterList")
@Composable
private fun ManageWalletContextMenu(
isMenuVisible: Boolean,
pressOffset: DpOffset,
itemHeight: Dp,
onDismissRequest: () -> Unit,
onShowRenameWalletDialogClick: () -> Unit,
onDeleteClick: () -> Unit,
) {
DropdownMenu(
expanded = isMenuVisible,
onDismissRequest = onDismissRequest,
modifier = Modifier.background(color = TangemTheme.colors.background.secondary),
offset = pressOffset.copy(y = pressOffset.y - itemHeight),
) {
MenuItem(
textResId = R.string.common_rename,
imageVector = Icons.Outlined.Edit,
onClick = {
onDismissRequest()
onShowRenameWalletDialogClick()
},
)
MenuItem(
textResId = R.string.common_delete,
imageVector = Icons.Outlined.Delete,
onClick = {
onDismissRequest()
onDeleteClick()
},
)
}
}
@Composable
private fun MenuItem(@StringRes textResId: Int, imageVector: ImageVector, onClick: () -> Unit) {
DropdownMenuItem(

View file

@ -19,7 +19,9 @@ import com.tangem.feature.wallet.presentation.wallet.ui.utils.ScrollOffsetCollec
@Composable
internal fun WalletSideEffects(lazyListState: LazyListState, walletsListConfig: WalletsListConfig) {
LaunchedEffect(key1 = walletsListConfig.selectedWalletIndex) {
lazyListState.scrollToItem(walletsListConfig.selectedWalletIndex)
if (!lazyListState.isScrollInProgress) {
lazyListState.animateScrollToItem(walletsListConfig.selectedWalletIndex)
}
}
val dragInteraction = lazyListState.interactionSource.interactions.collectAsState(initial = null)

View file

@ -1,8 +1,11 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.buttons.actions.RoundedActionButton
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.feature.wallet.impl.R
private const val ORGANIZE_BUTTON_CONTENT_TYPE = "OrganizeTokensButton"
@ -14,9 +17,20 @@ private const val ORGANIZE_BUTTON_CONTENT_TYPE = "OrganizeTokensButton"
*
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.organizeButton(onClick: (() -> Unit)?, modifier: Modifier = Modifier) {
internal fun LazyListScope.organizeTokensButton(
isEnabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
item(key = ORGANIZE_BUTTON_CONTENT_TYPE, contentType = ORGANIZE_BUTTON_CONTENT_TYPE) {
OrganizeTokensButton(onClick = onClick, modifier = modifier.animateItemPlacement())
RoundedActionButton(
modifier = modifier,
config = ActionButtonConfig(
text = resourceReference(id = R.string.organize_tokens_title),
iconResId = R.drawable.ic_filter_24,
onClick = onClick,
enabled = isEnabled,
),
)
}
}

View file

@ -1,29 +0,0 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.buttons.actions.RoundedActionButton
import com.tangem.core.ui.extensions.TextReference
import com.tangem.feature.wallet.impl.R
/**
* Organize tokens button
*
* @param onClick callback, if null button is disabled
* @param modifier modifier
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun OrganizeTokensButton(onClick: (() -> Unit)?, modifier: Modifier = Modifier) {
RoundedActionButton(
config = ActionButtonConfig(
text = TextReference.Res(id = R.string.organize_tokens_title),
iconResId = R.drawable.ic_filter_24,
onClick = onClick ?: {},
enabled = onClick != null,
),
modifier = modifier,
)
}

View file

@ -1,5 +1,6 @@
package com.tangem.feature.wallet.presentation.wallet.ui.utils
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
@ -27,7 +28,8 @@ internal class ScrollOffsetCollector(
private val LazyListItemInfo.halfItemSize get() = size.div(other = 2)
override suspend fun emit(value: List<LazyListItemInfo>) {
if (!lazyListState.isScrollInProgress || dragInteraction.value == null || value.size <= 1) return
if (isNotUserInteraction() || value.size <= 1) return
val firstItem = value.firstOrNull() ?: return
val lastItem = value.lastOrNull() ?: return
@ -37,4 +39,12 @@ internal class ScrollOffsetCollector(
callback(lastItem.index - 1)
}
}
/**
* Sometimes the list is scrolled programmatically. Example: selecting a specific wallet when a user opens the
* screen for the first time or scans a new wallet. Therefore [ScrollOffsetCollector] should not respond to changes.
*/
private fun isNotUserInteraction(): Boolean {
return !lazyListState.isScrollInProgress || dragInteraction.value !is DragInteraction.Start
}
}

View file

@ -2,12 +2,11 @@ package com.tangem.feature.wallet.presentation.wallet.utils
import com.tangem.common.Provider
import com.tangem.core.ui.components.marketprice.PriceChangeConfig
import com.tangem.core.ui.extensions.iconResId
import com.tangem.core.ui.extensions.networkBadgeIconResId
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.feature.wallet.presentation.common.utils.CryptoCurrencyToIconStateConverter
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletClickIntents
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
@ -18,6 +17,8 @@ internal class CryptoCurrencyStatusToTokenItemConverter(
private val clickIntents: WalletClickIntents,
) : Converter<CryptoCurrencyStatus, TokenItemState> {
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: CryptoCurrencyStatus): TokenItemState {
return when (value.value) {
is CryptoCurrencyStatus.Loading -> TokenItemState.Loading(id = value.currency.id.value)
@ -37,9 +38,7 @@ internal class CryptoCurrencyStatusToTokenItemConverter(
return TokenItemState.Content(
id = currency.id.value,
name = currency.name,
tokenIconUrl = currency.iconUrl,
tokenIconResId = currency.iconResId,
networkBadgeIconResId = currency.networkBadgeIconResId,
icon = iconStateConverter.convert(value = this),
amount = getFormattedAmount(),
hasPending = value.hasCurrentNetworkTransactions,
tokenOptions = if (isWalletContentHidden) {
@ -50,9 +49,8 @@ internal class CryptoCurrencyStatusToTokenItemConverter(
config = getPriceChangeConfig(),
)
},
isTestnet = currency.network.isTestnet,
onItemClick = { clickIntents.onTokenItemClick(currency) },
onItemLongClick = { clickIntents.onTokenItemLongClick(currency) },
onItemLongClick = { clickIntents.onTokenItemLongClick(cryptoCurrencyStatus = this) },
)
}
@ -72,9 +70,7 @@ internal class CryptoCurrencyStatusToTokenItemConverter(
private fun CryptoCurrencyStatus.mapToUnreachableTokenItemState() = TokenItemState.Unreachable(
id = currency.id.value,
name = currency.name,
tokenIconUrl = currency.iconUrl,
tokenIconResId = currency.iconResId,
networkBadgeIconResId = currency.networkBadgeIconResId,
icon = iconStateConverter.convert(value = this),
)
private fun CryptoCurrencyStatus.getPriceChangeConfig(): PriceChangeConfig {

View file

@ -0,0 +1,17 @@
package com.tangem.feature.wallet.presentation.wallet.utils
import com.tangem.common.Converter
import com.tangem.common.Provider
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.feature.wallet.presentation.wallet.state.WalletSingleCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletState
// TODO: Implement this
internal class CurrencyStatusErrorConverter(
private val currentStateProvider: Provider<WalletState>,
) : Converter<CurrencyStatusError, WalletSingleCurrencyState.Content> {
override fun convert(value: CurrencyStatusError): WalletSingleCurrencyState.Content {
return requireNotNull(currentStateProvider() as? WalletSingleCurrencyState.Content)
}
}

View file

@ -19,7 +19,7 @@ internal class TokenListErrorConverter(
state.copy(
tokensListState = WalletTokensListState.Content(
items = persistentListOf(),
onOrganizeTokensClick = null,
organizeTokensButton = WalletTokensListState.OrganizeTokensButtonState.Hidden,
),
)
}

View file

@ -7,8 +7,8 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.NetworkGroup
import com.tangem.domain.tokens.model.TokenList
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletTokensListState
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletTokensListState.OrganizeTokensButtonState
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletTokensListState.TokensListItemState
import com.tangem.feature.wallet.presentation.wallet.utils.LoadingItemsProvider.getLoadingMultiCurrencyTokens
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletClickIntents
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.PersistentList
@ -28,26 +28,15 @@ internal class TokenListToContentItemsConverter(
)
override fun convert(value: TokenList): WalletTokensListState {
val isEmptyList = when (value) {
is TokenList.GroupedByNetwork -> value.groups.isEmpty()
is TokenList.NotInitialized -> false
is TokenList.Ungrouped -> value.currencies.isEmpty()
}
return if (isEmptyList) {
WalletTokensListState.Empty
} else {
WalletTokensListState.Content(
items = when (value) {
is TokenList.GroupedByNetwork -> value.mapToMultiCurrencyItems()
is TokenList.Ungrouped -> value.mapToMultiCurrencyItems()
is TokenList.NotInitialized -> getLoadingMultiCurrencyTokens()
},
onOrganizeTokensClick = if (value.totalFiatBalance is TokenList.FiatBalance.Loaded) {
clickIntents::onOrganizeTokensClick
} else {
null
},
return when (value) {
is TokenList.NotInitialized -> WalletTokensListState.Loading()
is TokenList.GroupedByNetwork -> WalletTokensListState.Content(
items = value.mapToMultiCurrencyItems(),
organizeTokensButton = value.mapToOrganizeTokensButtonState(),
)
is TokenList.Ungrouped -> WalletTokensListState.Content(
items = value.mapToMultiCurrencyItems(),
organizeTokensButton = value.mapToOrganizeTokensButtonState(),
)
}
}
@ -64,6 +53,20 @@ internal class TokenListToContentItemsConverter(
}
}
private fun TokenList.GroupedByNetwork.mapToOrganizeTokensButtonState(): OrganizeTokensButtonState {
return getOrganizeTokensButtonState(
isLoading = totalFiatBalance is TokenList.FiatBalance.Loading,
currenciesSize = groups.flatMap(NetworkGroup::currencies).size,
)
}
private fun TokenList.Ungrouped.mapToOrganizeTokensButtonState(): OrganizeTokensButtonState {
return getOrganizeTokensButtonState(
isLoading = totalFiatBalance is TokenList.FiatBalance.Loading,
currenciesSize = currencies.size,
)
}
private fun MutableList<TokensListItemState>.addGroup(group: NetworkGroup): List<TokensListItemState> {
this.add(TokensListItemState.NetworkGroupTitle(TextReference.Str(group.network.name)))
@ -81,4 +84,15 @@ internal class TokenListToContentItemsConverter(
return this
}
private fun getOrganizeTokensButtonState(isLoading: Boolean, currenciesSize: Int): OrganizeTokensButtonState {
return if (currenciesSize > 1) {
OrganizeTokensButtonState.Visible(
isEnabled = !isLoading,
onClick = clickIntents::onOrganizeTokensClick,
)
} else {
OrganizeTokensButtonState.Hidden
}
}
}

View file

@ -6,9 +6,9 @@ import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.tokens.model.TokenList
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.wallet.presentation.wallet.state.WalletMultiCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletSingleCurrencyState
import com.tangem.feature.wallet.presentation.wallet.state.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletsListConfig
import com.tangem.feature.wallet.presentation.wallet.utils.TokenListToWalletStateConverter.TokensListModel
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletClickIntents
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
@ -21,7 +21,7 @@ internal class TokenListToWalletStateConverter(
private val appCurrencyProvider: Provider<AppCurrency>,
private val isWalletContentHidden: Boolean,
clickIntents: WalletClickIntents,
) : Converter<TokensListModel, WalletMultiCurrencyState.Content> {
) : Converter<TokenList, WalletState> {
private val tokenListToContentConverter = TokenListToContentItemsConverter(
isWalletContentHidden = isWalletContentHidden,
@ -29,17 +29,20 @@ internal class TokenListToWalletStateConverter(
clickIntents = clickIntents,
)
override fun convert(value: TokensListModel): WalletMultiCurrencyState.Content {
val state = requireNotNull(currentStateProvider() as? WalletMultiCurrencyState.Content)
return state.copy(
walletsListConfig = state.updateSelectedWallet(fiatBalance = value.tokenList.totalFiatBalance),
pullToRefreshConfig = if (value.isRefreshing) {
state.pullToRefreshConfig.copy(isRefreshing = getRefreshingStatus(tokenList = value.tokenList))
} else {
state.pullToRefreshConfig
},
tokensListState = tokenListToContentConverter.convert(value = value.tokenList),
)
override fun convert(value: TokenList): WalletState {
return when (val state = currentStateProvider()) {
is WalletMultiCurrencyState.Content -> {
state.copy(
walletsListConfig = state.updateSelectedWallet(fiatBalance = value.totalFiatBalance),
tokensListState = tokenListToContentConverter.convert(value = value),
)
}
is WalletMultiCurrencyState.Locked,
is WalletSingleCurrencyState.Content,
is WalletSingleCurrencyState.Locked,
is WalletState.Initial,
-> state
}
}
private fun WalletMultiCurrencyState.updateSelectedWallet(fiatBalance: TokenList.FiatBalance): WalletsListConfig {
@ -58,10 +61,4 @@ internal class TokenListToWalletStateConverter(
.set(index = selectedWalletIndex, element = converter.convert(fiatBalance)),
)
}
private fun getRefreshingStatus(tokenList: TokenList): Boolean {
return tokenList.totalFiatBalance is TokenList.FiatBalance.Loading
}
data class TokensListModel(val tokenList: TokenList, val isRefreshing: Boolean)
}

View file

@ -1,6 +1,7 @@
package com.tangem.feature.wallet.presentation.wallet.viewmodels
import com.tangem.core.ui.components.transactions.intents.TxHistoryClickIntents
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.models.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
@ -11,6 +12,10 @@ internal interface WalletClickIntents : TxHistoryClickIntents {
fun onScanCardClick()
fun onScanCardNotificationClick()
fun onScanToUnlockWalletClick()
fun onDetailsClick()
fun onBackupCardClick()
@ -39,7 +44,7 @@ internal interface WalletClickIntents : TxHistoryClickIntents {
fun onTokenItemClick(currency: CryptoCurrency)
fun onTokenItemLongClick(currency: CryptoCurrency)
fun onTokenItemLongClick(cryptoCurrencyStatus: CryptoCurrencyStatus)
fun onDismissActionsBottomSheet()
@ -47,7 +52,9 @@ internal interface WalletClickIntents : TxHistoryClickIntents {
fun onDeleteClick(userWalletId: UserWalletId)
fun onSendClick()
fun onSingleCurrencySendClick(cryptoCurrencyStatus: CryptoCurrencyStatus? = null)
fun onMultiCurrencySendClick(cryptoCurrencyStatus: CryptoCurrencyStatus)
fun onReceiveClick()

View file

@ -59,7 +59,7 @@ internal class WalletNotificationsListFactory(
}
if (tokenList != null && tokenList.hasMissedDerivations()) {
add(element = WalletNotification.ScanCard(onClick = clickIntents::onScanCardClick))
add(element = WalletNotification.ScanCard(onClick = clickIntents::onScanCardNotificationClick))
}
if (isUserAlreadyRateAppCallback()) {

View file

@ -10,13 +10,13 @@ import com.tangem.feature.wallet.presentation.wallet.state.WalletState
*/
internal object WalletStateCache {
private val states = mutableMapOf<UserWalletId, WalletState>()
private val states = mutableMapOf<UserWalletId, WalletState.ContentState>()
/** Get state by [userWalletId] */
fun getState(userWalletId: UserWalletId): WalletState? = states[userWalletId]
fun getState(userWalletId: UserWalletId): WalletState.ContentState? = states[userWalletId]
/** Add or update [state] by [userWalletId] */
fun update(userWalletId: UserWalletId, state: WalletState) {
fun update(userWalletId: UserWalletId, state: WalletState.ContentState) {
states[userWalletId] = state
}
}

View file

@ -21,9 +21,7 @@ import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.settings.CanUseBiometryUseCase
import com.tangem.domain.settings.IsUserAlreadyRateAppUseCase
import com.tangem.domain.settings.ShouldShowSaveWalletScreenUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase
import com.tangem.domain.tokens.GetPrimaryCurrencyStatusUpdatesUseCase
import com.tangem.domain.tokens.GetTokenListUseCase
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.TokenList
@ -49,6 +47,7 @@ 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.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -65,7 +64,7 @@ import kotlin.properties.Delegates
internal class WalletViewModel @Inject constructor(
private val getWalletsUseCase: GetWalletsUseCase,
private val saveWalletUseCase: SaveWalletUseCase,
private val getSelectedWalletUseCase: GetSelectedWalletUseCase,
getSelectedWalletUseCase: GetSelectedWalletUseCase,
private val selectWalletUseCase: SelectWalletUseCase,
private val updateWalletUseCase: UpdateWalletUseCase,
private val deleteWalletUseCase: DeleteWalletUseCase,
@ -73,7 +72,10 @@ internal class WalletViewModel @Inject constructor(
private val setAccessCodeRequestPolicyUseCase: SetAccessCodeRequestPolicyUseCase,
private val getAccessCodeSavingStatusUseCase: GetAccessCodeSavingStatusUseCase,
private val getTokenListUseCase: GetTokenListUseCase,
private val getPrimaryCurrencyUseCase: GetPrimaryCurrencyStatusUpdatesUseCase,
private val fetchTokenListUseCase: FetchTokenListUseCase,
private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase,
private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase,
private val getNetworkCoinStatusUseCase: GetNetworkCoinStatusUseCase,
private val getCardWasScannedUseCase: GetCardWasScannedUseCase,
private val isUserAlreadyRateAppUseCase: IsUserAlreadyRateAppUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase,
@ -121,12 +123,18 @@ internal class WalletViewModel @Inject constructor(
var uiState: WalletState by uiStateHolder(initialState = stateFactory.getInitialState())
private var wallets: List<UserWallet> by Delegates.notNull()
private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null
private var singleWalletCryptoCurrencyStatus: CryptoCurrencyStatus? = null
private val tokensJobHolder = JobHolder()
private val marketPriceJobHolder = JobHolder()
private val buttonsJobHolder = JobHolder()
private val notificationsJobHolder = JobHolder()
private val refreshContentJobHolder = JobHolder()
private val walletsUpdateActionResolver = WalletsUpdateActionResolver(
currentStateProvider = Provider { uiState },
getSelectedWalletUseCase = getSelectedWalletUseCase,
)
override fun onCreate(owner: LifecycleOwner) {
viewModelScope.launch(dispatchers.main) {
@ -146,154 +154,51 @@ internal class WalletViewModel @Inject constructor(
}
private fun updateWallets(sourceList: List<UserWallet>) {
if (sourceList.isEmpty()) return
wallets = sourceList
val currentState = uiState
val selectedWalletIndex = if (currentState is WalletLockedState) {
currentState.getSelectedWalletIndex()
if (sourceList.isEmpty()) return
when (val action = walletsUpdateActionResolver.resolve(sourceList)) {
is WalletsUpdateActionResolver.Action.InitialWallets -> {
loadAndUpdateState(index = action.selectedWalletIndex)
}
is WalletsUpdateActionResolver.Action.UpdateWalletName -> {
uiState = stateFactory.getStateWithUpdatedWalletName(name = action.name)
}
is WalletsUpdateActionResolver.Action.UnlockWallet -> {
uiState = stateFactory.getUnlockedState(action)
getContentItemsUpdates(index = action.selectedWalletIndex)
}
is WalletsUpdateActionResolver.Action.DeleteWallet -> {
deleteWalletAndUpdateState(action = action)
}
is WalletsUpdateActionResolver.Action.AddWallet -> {
loadAndUpdateState(index = action.selectedWalletIndex)
}
is WalletsUpdateActionResolver.Action.Unknown -> Unit
}
}
private fun loadAndUpdateState(index: Int) {
uiState = stateFactory.getSkeletonState(wallets = wallets, selectedWalletIndex = index)
getContentItemsUpdates(index = index)
}
private fun deleteWalletAndUpdateState(action: WalletsUpdateActionResolver.Action.DeleteWallet) {
val cacheState = WalletStateCache.getState(userWalletId = action.selectedWalletId)
if (cacheState != null) {
uiState = stateFactory.getStateWithoutDeletedWallet(cacheState, action)
if (cacheState.isLoadingState()) {
getContentItemsUpdates(action.selectedWalletIndex)
}
} else {
val selectedWallet = getSelectedWalletUseCase().fold(
ifLeft = { error("Selected wallet is null") },
ifRight = { it },
)
sourceList.indexOfFirst { it.walletId == selectedWallet.walletId }
}
uiState = stateFactory.getSkeletonState(wallets = sourceList, selectedWalletIndex = selectedWalletIndex)
updateContentItems(index = selectedWalletIndex)
}
private fun updateContentItems(index: Int, isRefreshing: Boolean = false) {
val cardTypeResolver = getCardTypeResolver(index)
when {
getWallet(index).isLocked -> uiState = stateFactory.getLockedState()
cardTypeResolver.isMultiwalletAllowed() -> updateMultiCurrencyContent(index, isRefreshing)
!cardTypeResolver.isMultiwalletAllowed() -> updateSingleCurrencyContent(index, isRefreshing)
loadAndUpdateState(index = action.selectedWalletIndex)
}
}
private fun updateMultiCurrencyContent(index: Int, isRefreshing: Boolean = false) {
val state = requireNotNull(uiState as? WalletMultiCurrencyState) {
"Impossible to update tokens list if state isn't WalletMultiCurrencyState"
}
getTokenListUseCase(userWalletId = state.walletsListConfig.wallets[index].id)
.distinctUntilChanged()
.onEach { tokenListEither ->
uiState = stateFactory.getStateByTokensList(
tokenListEither = tokenListEither,
isRefreshing = isRefreshing,
)
updateNotifications(
index = index,
tokenList = tokenListEither.fold(ifLeft = { null }, ifRight = { it }),
)
}
.flowOn(dispatchers.io)
.launchIn(viewModelScope)
.saveIn(tokensJobHolder)
}
private fun updateSingleCurrencyContent(index: Int, isRefreshing: Boolean) {
val wallet = getWallet(index)
val blockchain = getCardTypeResolver(index).getBlockchain()
updateButtons(userWalletId = wallet.walletId, currencyId = blockchain.id)
updateTxHistory(
blockchain = blockchain,
derivationStyle = wallet.scanResponse.derivationStyleProvider.getDerivationStyle(),
)
updateMarketPrice(userWalletId = wallet.walletId, isRefreshing = isRefreshing)
updateNotifications(index)
}
private fun updateTxHistory(blockchain: Blockchain, derivationStyle: DerivationStyle?) {
viewModelScope.launch(dispatchers.io) {
val derivationPath = blockchain.derivationPath(style = derivationStyle)?.rawPath
val txHistoryItemsCountEither = txHistoryItemsCountUseCase(
networkId = Network.ID(blockchain.id),
derivationPath = derivationPath,
)
uiState = stateFactory.getLoadingTxHistoryState(itemsCountEither = txHistoryItemsCountEither)
txHistoryItemsCountEither.onRight {
uiState = stateFactory.getLoadedTxHistoryState(
txHistoryEither = txHistoryItemsUseCase(
networkId = Network.ID(blockchain.id),
derivationPath = derivationPath,
).map {
it.cachedIn(viewModelScope)
},
)
}
}
}
// It also update wallet balance
private fun updateMarketPrice(userWalletId: UserWalletId, isRefreshing: Boolean) {
getPrimaryCurrencyUseCase(userWalletId = userWalletId)
.distinctUntilChanged()
.onEach { either ->
uiState = stateFactory.getSingleCurrencyLoadedBalanceState(
cryptoCurrencyEither = either,
isRefreshing = isRefreshing,
)
either.onRight { status -> cryptoCurrencyStatus = status }
}
.flowOn(dispatchers.io)
.launchIn(viewModelScope)
.saveIn(marketPriceJobHolder)
}
private fun updateButtons(userWalletId: UserWalletId, currencyId: String) {
getCryptoCurrencyActionsUseCase(userWalletId = userWalletId, tokenId = currencyId)
.distinctUntilChanged()
.onEach { uiState = stateFactory.getSingleCurrencyManageButtonsState(actions = it.states) }
.flowOn(dispatchers.io)
.launchIn(viewModelScope)
.saveIn(buttonsJobHolder)
}
private fun updateNotifications(index: Int, tokenList: TokenList? = null) {
notificationsListFactory.create(
cardTypesResolver = getCardTypeResolver(index = index),
tokenList = tokenList,
)
.distinctUntilChanged()
.onEach { uiState = stateFactory.getStateByNotifications(notifications = it) }
.flowOn(dispatchers.io)
.launchIn(viewModelScope)
.saveIn(notificationsJobHolder)
}
override fun onStop(owner: LifecycleOwner) {
viewModelScope.launch(dispatchers.io) {
saveSelectedWallet()
}
}
private suspend fun saveSelectedWallet() {
val state = uiState
if (state is WalletState.ContentState) {
selectWalletUseCase(getWallet(index = state.walletsListConfig.selectedWalletIndex).walletId)
}
}
private fun getWallet(index: Int): UserWallet {
return requireNotNull(
value = wallets.getOrNull(index),
lazyMessage = { "WalletsList doesn't contain element with index = $index" },
)
}
private fun getCardTypeResolver(index: Int): CardTypesResolver = getWallet(index).scanResponse.cardTypesResolver
override fun onBackClick() {
viewModelScope.launch(dispatchers.main) {
router.popBackStack(screen = if (shouldSaveUserWalletsUseCase()) AppScreen.Welcome else AppScreen.Home)
@ -301,23 +206,56 @@ internal class WalletViewModel @Inject constructor(
}
override fun onScanCardClick() {
viewModelScope.launch(dispatchers.io) {
scanCardProcessor.scan()
.doOnSuccess {
// If card's public key is null then user wallet will be null
val userWallet = UserWalletBuilder(scanResponse = it).build()
if (userWallet != null) {
saveWalletUseCase(userWallet = userWallet, canOverride = false)
}
}
}
}
override fun onScanCardNotificationClick() {
scanToUpdateSelectedWallet(
onSuccessSave = {
// Reload currencies with missed derivation
fetchTokenListUseCase(userWalletId = it.walletId)
},
)
}
override fun onScanToUnlockWalletClick() {
scanToUpdateSelectedWallet()
}
private fun scanToUpdateSelectedWallet(onSuccessSave: suspend (UserWallet) -> Unit = {}) {
val state = uiState as? WalletState.ContentState ?: return
val prevRequestPolicyStatus = getBiometricsStatusUseCase()
// Update access the code policy according access code saving status
setAccessCodeRequestPolicyUseCase(isBiometricsRequestPolicy = getAccessCodeSavingStatusUseCase())
viewModelScope.launch(dispatchers.io) {
scanCardProcessor.scan(allowsRequestAccessCodeFromRepository = true)
scanCardProcessor.scan(
cardId = getWallet(state.walletsListConfig.selectedWalletIndex).cardId,
allowsRequestAccessCodeFromRepository = true,
)
.doOnSuccess {
// If card's public key is null then user wallet will be null
val userWallet = UserWalletBuilder(scanResponse = it).build()
if (userWallet != null) {
saveWalletUseCase(userWallet)
saveWalletUseCase(userWallet = userWallet, canOverride = true)
.onLeft {
// Rollback policy if card saving was failed
setAccessCodeRequestPolicyUseCase(prevRequestPolicyStatus)
}
.onRight { onSuccessSave(userWallet) }
} else {
// Rollback policy if card saving was failed
setAccessCodeRequestPolicyUseCase(prevRequestPolicyStatus)
@ -365,76 +303,61 @@ internal class WalletViewModel @Inject constructor(
}
override fun onWalletChange(index: Int) {
val state = requireNotNull(uiState as? WalletState.ContentState) {
"Impossible to change wallet if state isn't WalletState.ContentState"
}
val state = uiState as? WalletState.ContentState ?: return
if (state.walletsListConfig.selectedWalletIndex == index) return
/*
* When wallet is changed it's necessary to stop the last jobs.
* If jobs aren't stopped and wallet is changed then it will update state for the prev wallet.
*/
tokensJobHolder.update(job = null)
marketPriceJobHolder.update(job = null)
buttonsJobHolder.update(job = null)
notificationsJobHolder.update(job = null)
viewModelScope.launch(dispatchers.io) {
selectWalletUseCase(getWallet(index = index).walletId)
}
val cacheState = WalletStateCache.getState(userWalletId = state.walletsListConfig.wallets[index].id)
if (cacheState != null) {
uiState = if (cacheState is WalletState.ContentState) {
cacheState.copySealed(
walletsListConfig = state.walletsListConfig.copy(selectedWalletIndex = index),
pullToRefreshConfig = state.pullToRefreshConfig.copy(isRefreshing = false),
)
} else {
cacheState
}
if (cacheState != null && cacheState !is WalletLockedState) {
uiState = cacheState.copySealed(
walletsListConfig = state.walletsListConfig.copy(
selectedWalletIndex = index,
wallets = state.walletsListConfig.wallets
.mapIndexed { mapIndex, currentWallet ->
val cacheWallet = cacheState.walletsListConfig.wallets.getOrNull(mapIndex)
if (cacheState.isLoadingState()) updateContentItems(index)
if (currentWallet is WalletCardState.Loading && cacheWallet != null &&
cacheWallet.isLoaded()
) {
cacheWallet
} else {
currentWallet
}
}
.toImmutableList(),
),
pullToRefreshConfig = cacheState.pullToRefreshConfig.copy(isRefreshing = false),
)
if (cacheState.isLoadingState()) {
getContentItemsUpdates(index)
}
} else {
uiState = stateFactory.getSkeletonState(wallets = wallets, selectedWalletIndex = index)
updateContentItems(index = index)
getContentItemsUpdates(index = index)
}
}
private fun WalletState.isLoadingState(): Boolean {
// Check the base components
if (this is WalletState.ContentState) {
walletsListConfig.wallets[walletsListConfig.selectedWalletIndex] is WalletCardState.Loading ||
notifications.isEmpty()
}
// Check the special components
return when (this) {
is WalletMultiCurrencyState -> {
val hasLoadingTokens = tokensListState is WalletTokensListState.ContentState &&
(tokensListState as WalletTokensListState.ContentState).items
.filterIsInstance<WalletTokensListState.TokensListItemState.Token>()
.any { it.state is TokenItemState.Loading }
tokensListState is WalletTokensListState.Loading || hasLoadingTokens
}
is WalletSingleCurrencyState -> {
this is WalletSingleCurrencyState.Content && marketPriceBlockState is MarketPriceBlockState.Loading
}
is WalletState.Initial -> false
}
private fun WalletCardState.isLoaded(): Boolean {
return this !is WalletCardState.Loading && this !is WalletCardState.LockedContent
}
override fun onRefreshSwipe() {
if (uiState is WalletState.Initial || uiState is WalletLockedState) return
val selectedWalletIndex = (uiState as? WalletState.ContentState)
?.walletsListConfig
?.selectedWalletIndex
?: return
viewModelScope.launch(dispatchers.io) {
uiState = stateFactory.getStateAfterContentRefreshing()
// TODO: [REDACTED_JIRA]
delay(timeMillis = 500)
updateContentItems(
index = requireNotNull(uiState as? WalletState.ContentState).walletsListConfig.selectedWalletIndex,
isRefreshing = true,
)
when (uiState) {
is WalletMultiCurrencyState.Content -> refreshMultiCurrencyContent(selectedWalletIndex)
is WalletSingleCurrencyState.Content -> refreshSingleCurrencyContent(selectedWalletIndex)
is WalletState.Initial,
is WalletMultiCurrencyState.Locked,
is WalletSingleCurrencyState.Locked,
-> Unit
}
}
@ -448,7 +371,7 @@ internal class WalletViewModel @Inject constructor(
override fun onBuyClick() {
val state = uiState as? WalletState.ContentState ?: return
val status = cryptoCurrencyStatus ?: return
val status = singleWalletCryptoCurrencyStatus ?: return
val wallet = getWallet(index = state.walletsListConfig.selectedWalletIndex)
reduxStateHolder.dispatch(
@ -460,8 +383,48 @@ internal class WalletViewModel @Inject constructor(
)
}
override fun onSendClick() {
reduxStateHolder.dispatch(TradeCryptoAction.New.Send)
override fun onSingleCurrencySendClick(cryptoCurrencyStatus: CryptoCurrencyStatus?) {
val state = uiState as? WalletState.ContentState ?: return
val userWallet = getWallet(index = state.walletsListConfig.selectedWalletIndex)
val coinStatus = if (userWallet.isMultiCurrency) cryptoCurrencyStatus else singleWalletCryptoCurrencyStatus
reduxStateHolder.dispatch(
action = TradeCryptoAction.New.SendCoin(
userWallet = userWallet,
coinStatus = coinStatus ?: return,
),
)
}
override fun onMultiCurrencySendClick(cryptoCurrencyStatus: CryptoCurrencyStatus) {
if (cryptoCurrencyStatus.currency is CryptoCurrency.Coin) {
onSingleCurrencySendClick(cryptoCurrencyStatus = cryptoCurrencyStatus)
return
}
val state = uiState as? WalletState.ContentState ?: return
viewModelScope.launch(dispatchers.io) {
val userWallet = getWallet(index = state.walletsListConfig.selectedWalletIndex)
getNetworkCoinStatusUseCase(
userWalletId = userWallet.walletId,
networkId = cryptoCurrencyStatus.currency.network.id,
)
.take(count = 1)
.collectLatest {
it.onRight { coinStatus ->
reduxStateHolder.dispatch(
action = TradeCryptoAction.New.SendToken(
userWallet = getWallet(index = state.walletsListConfig.selectedWalletIndex),
tokenStatus = cryptoCurrencyStatus,
coinFiatRate = coinStatus.value.fiatRate,
),
)
}
}
}
}
override fun onReceiveClick() {
@ -469,7 +432,7 @@ internal class WalletViewModel @Inject constructor(
}
override fun onSellClick() {
val status = cryptoCurrencyStatus ?: return
val status = singleWalletCryptoCurrencyStatus ?: return
reduxStateHolder.dispatch(
TradeCryptoAction.New.Sell(
@ -480,15 +443,17 @@ internal class WalletViewModel @Inject constructor(
}
override fun onManageTokensClick() {
reduxStateHolder.dispatch(action = TokensAction.SetArgs.ManageAccess)
router.openManageTokensScreen()
}
override fun onReloadClick() {
uiState = stateFactory.getStateAfterContentRefreshing()
updateSingleCurrencyContent(
index = requireNotNull(uiState as? WalletState.ContentState).walletsListConfig.selectedWalletIndex,
isRefreshing = true,
)
val selectedWalletIndex = (uiState as? WalletSingleCurrencyState)
?.walletsListConfig
?.selectedWalletIndex
?: return
refreshSingleCurrencyContent(selectedWalletIndex)
}
override fun onExploreClick() {
@ -530,10 +495,8 @@ internal class WalletViewModel @Inject constructor(
router.openTokenDetails(currency = currency)
}
override fun onTokenItemLongClick(currency: CryptoCurrency) {
uiState = stateFactory.getStateWithTokenActionBottomSheet(
tokenId = currency.id.value,
)
override fun onTokenItemLongClick(cryptoCurrencyStatus: CryptoCurrencyStatus) {
uiState = stateFactory.getStateWithTokenActionBottomSheet(cryptoCurrencyStatus)
}
override fun onRenameClick(userWalletId: UserWalletId, name: String) {
@ -543,26 +506,15 @@ internal class WalletViewModel @Inject constructor(
}
override fun onDeleteClick(userWalletId: UserWalletId) {
val state = uiState as? WalletState.ContentState ?: return
viewModelScope.launch(dispatchers.io) {
val either = deleteWalletUseCase(userWalletId)
val state = requireNotNull(uiState as? WalletState.ContentState)
if (state.walletsListConfig.wallets.size <= 1 && either.isRight()) onBackClick()
}
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
}
override fun onDismissBottomSheet() {
uiState = stateFactory.getStateWithClosedBottomSheet()
}
@ -576,4 +528,189 @@ internal class WalletViewModel @Inject constructor(
)
}
}
private fun getContentItemsUpdates(index: Int) {
/*
* When wallet is changed it's necessary to stop the last jobs.
* If jobs aren't stopped and wallet is changed then it will update state for the prev wallet.
*/
tokensJobHolder.update(job = null)
marketPriceJobHolder.update(job = null)
buttonsJobHolder.update(job = null)
notificationsJobHolder.update(job = null)
refreshContentJobHolder.update(job = null)
val wallet = getWallet(index)
when {
wallet.isLocked -> {
uiState = stateFactory.getLockedState()
}
wallet.isMultiCurrency -> getMultiCurrencyContent(index)
!wallet.isMultiCurrency -> getSingleCurrencyContent(index)
}
}
private fun getMultiCurrencyContent(walletIndex: Int) {
val state = requireNotNull(uiState as? WalletMultiCurrencyState) {
"Impossible to get a token list updates if state isn't WalletMultiCurrencyState"
}
getTokenListUseCase(userWalletId = state.walletsListConfig.wallets[walletIndex].id)
.distinctUntilChanged()
.onEach { maybeTokenList ->
uiState = stateFactory.getStateByTokensList(maybeTokenList)
updateNotifications(
index = walletIndex,
tokenList = maybeTokenList.fold(ifLeft = { null }, ifRight = { it }),
)
}
.flowOn(dispatchers.io)
.launchIn(viewModelScope)
.saveIn(tokensJobHolder)
}
private fun getSingleCurrencyContent(index: Int) {
val wallet = getWallet(index)
val blockchain = getCardTypeResolver(index).getBlockchain()
updateTxHistory(
blockchain = blockchain,
derivationStyle = wallet.scanResponse.derivationStyleProvider.getDerivationStyle(),
)
updateMarketPrice(userWalletId = wallet.walletId)
updateNotifications(index)
}
private fun updateTxHistory(blockchain: Blockchain, derivationStyle: DerivationStyle?) {
viewModelScope.launch(dispatchers.io) {
val derivationPath = blockchain.derivationPath(style = derivationStyle)?.rawPath
val txHistoryItemsCountEither = txHistoryItemsCountUseCase(
networkId = Network.ID(blockchain.id),
derivationPath = derivationPath,
)
uiState = stateFactory.getLoadingTxHistoryState(itemsCountEither = txHistoryItemsCountEither)
txHistoryItemsCountEither.onRight {
uiState = stateFactory.getLoadedTxHistoryState(
txHistoryEither = txHistoryItemsUseCase(
networkId = Network.ID(blockchain.id),
derivationPath = derivationPath,
).map {
it.cachedIn(viewModelScope)
},
)
}
}
}
// It also update wallet balance
private fun updateMarketPrice(userWalletId: UserWalletId) {
getPrimaryCurrencyStatusUpdatesUseCase(userWalletId = userWalletId)
.distinctUntilChanged()
.onEach { maybeCryptoCurrencyStatus ->
uiState = stateFactory.getSingleCurrencyLoadedBalanceState(maybeCryptoCurrencyStatus)
maybeCryptoCurrencyStatus.onRight { status ->
singleWalletCryptoCurrencyStatus = status
updateButtons(userWalletId = userWalletId, currency = status.currency)
}
}
.flowOn(dispatchers.io)
.launchIn(viewModelScope)
.saveIn(marketPriceJobHolder)
}
private fun updateButtons(userWalletId: UserWalletId, currency: CryptoCurrency) {
getCryptoCurrencyActionsUseCase(userWalletId = userWalletId, cryptoCurrency = currency)
.distinctUntilChanged()
.onEach { uiState = stateFactory.getSingleCurrencyManageButtonsState(actions = it.states) }
.flowOn(dispatchers.io)
.launchIn(viewModelScope)
.saveIn(buttonsJobHolder)
}
private fun updateNotifications(index: Int, tokenList: TokenList? = null) {
notificationsListFactory.create(
cardTypesResolver = getCardTypeResolver(index = index),
tokenList = tokenList,
)
.distinctUntilChanged()
.onEach { uiState = stateFactory.getStateByNotifications(notifications = it) }
.flowOn(dispatchers.io)
.launchIn(viewModelScope)
.saveIn(notificationsJobHolder)
}
private fun refreshMultiCurrencyContent(walletIndex: Int) {
uiState = stateFactory.getRefreshingState()
val wallet = getWallet(walletIndex)
viewModelScope.launch(dispatchers.io) {
val result = fetchTokenListUseCase(wallet.walletId, refresh = true)
uiState = stateFactory.getRefreshedState()
uiState = result.fold(stateFactory::getStateByTokenListError) { uiState }
}.saveIn(refreshContentJobHolder)
}
private fun refreshSingleCurrencyContent(walletIndex: Int) {
uiState = stateFactory.getRefreshingState()
val wallet = getWallet(walletIndex)
viewModelScope.launch(dispatchers.io) {
val result = fetchCurrencyStatusUseCase(wallet.walletId, refresh = true)
uiState = stateFactory.getRefreshedState()
uiState = result.fold(stateFactory::getStateByCurrencyStatusError) { uiState }
}.saveIn(refreshContentJobHolder)
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
}
private fun WalletState.isLoadingState(): Boolean {
// Check the base components
if (this is WalletState.ContentState &&
walletsListConfig.wallets[walletsListConfig.selectedWalletIndex] is WalletCardState.Loading
) {
return true
}
// Check the special components
return when (this) {
is WalletMultiCurrencyState -> {
val hasLoadingTokens = tokensListState is WalletTokensListState.ContentState &&
(tokensListState as WalletTokensListState.ContentState).items
.filterIsInstance<WalletTokensListState.TokensListItemState.Token>()
.any { it.state is TokenItemState.Loading }
tokensListState is WalletTokensListState.Loading || hasLoadingTokens
}
is WalletSingleCurrencyState -> {
this is WalletSingleCurrencyState.Content && marketPriceBlockState is MarketPriceBlockState.Loading
}
is WalletState.Initial -> false
}
}
private fun getWallet(index: Int): UserWallet {
return requireNotNull(
value = wallets.getOrNull(index),
lazyMessage = { "WalletsList doesn't contain element with index = $index" },
)
}
private fun getCardTypeResolver(index: Int): CardTypesResolver = getWallet(index).scanResponse.cardTypesResolver
}

View file

@ -0,0 +1,161 @@
package com.tangem.feature.wallet.presentation.wallet.viewmodels
import com.tangem.common.Provider
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.feature.wallet.presentation.wallet.state.WalletLockedState
import com.tangem.feature.wallet.presentation.wallet.state.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletCardState
/**
* Resolver that determines which update action will be performed
*
* @property currentStateProvider current state provider
* @property getSelectedWalletUseCase use case that returns selected wallet
*/
internal class WalletsUpdateActionResolver(
private val currentStateProvider: Provider<WalletState>,
private val getSelectedWalletUseCase: GetSelectedWalletUseCase,
) {
fun resolve(wallets: List<UserWallet>): Action {
val selectedWallet = wallets.getSelectedWallet()
return when (val state = currentStateProvider()) {
is WalletState.Initial -> {
Action.InitialWallets(
selectedWalletIndex = wallets.indexOfWallet(id = selectedWallet.walletId),
)
}
is WalletState.ContentState -> {
getActionToUpdateContent(state = state, wallets = wallets, selectedWallet = selectedWallet)
}
}
}
private fun List<UserWallet>.getSelectedWallet(): UserWallet {
val hasUnlockedWallet = any { !it.isLocked }
return if (hasUnlockedWallet) {
val selectedWalletId = getSelectedWalletUseCase().fold(ifLeft = ::error, ifRight = UserWallet::walletId)
firstOrNull { it.walletId == selectedWalletId }
?: error("Wallets don't contain a wallet with id: $selectedWalletId")
} else {
lastOrNull() ?: error("Wallets is empty")
}
}
private fun getActionToUpdateContent(
state: WalletState.ContentState,
wallets: List<UserWallet>,
selectedWallet: UserWallet,
): Action {
return if (isWalletsCountChanged(state, wallets)) {
getActionToChangeWallets(state = state, wallets = wallets, selectedWallet = selectedWallet)
} else {
getActionToUpdateCurrentWallet(state = state, wallets = wallets, selectedWallet = selectedWallet)
}
}
private fun isWalletsCountChanged(state: WalletState.ContentState, wallets: List<UserWallet>): Boolean {
val prevWalletsSize = state.walletsListConfig.wallets.size
val walletsSize = wallets.size
return prevWalletsSize != walletsSize
}
private fun getActionToChangeWallets(
state: WalletState.ContentState,
wallets: List<UserWallet>,
selectedWallet: UserWallet,
): Action {
val prevWalletsSize = state.walletsListConfig.wallets.size
return when {
prevWalletsSize > wallets.size -> {
Action.DeleteWallet(
selectedWalletId = selectedWallet.walletId,
selectedWalletIndex = wallets.indexOfWallet(id = selectedWallet.walletId),
deletedWalletId = state.walletsListConfig.wallets.getDeletedWalletId(wallets),
)
}
prevWalletsSize < wallets.size -> {
Action.AddWallet(
selectedWalletIndex = wallets.indexOfWallet(id = selectedWallet.walletId),
)
}
else -> Action.Unknown
}
}
private fun List<WalletCardState>.getDeletedWalletId(wallets: List<UserWallet>): UserWalletId {
return this
.map(WalletCardState::id)
.firstOrNull { !wallets.map(UserWallet::walletId).contains(it) }
?: error("Deleted wallet id is not found. Wallets contains all previous wallets ids")
}
private fun getActionToUpdateCurrentWallet(
state: WalletState.ContentState,
wallets: List<UserWallet>,
selectedWallet: UserWallet,
): Action {
val selectedWalletName = selectedWallet.name
if (state.getPrevSelectedWalletName() != selectedWalletName) {
return Action.UpdateWalletName(selectedWalletName)
}
if (state is WalletLockedState && !selectedWallet.isLocked) {
return Action.UnlockWallet(
selectedWalletIndex = wallets.indexOfWallet(id = selectedWallet.walletId),
selectedWallet = selectedWallet,
unlockedWallets = wallets.filterNot(UserWallet::isLocked),
)
}
return Action.Unknown
}
private fun WalletState.ContentState.getPrevSelectedWalletName(): String {
val prevSelectedWalletIndex = walletsListConfig.selectedWalletIndex
val prevSelectedWallet = walletsListConfig.wallets.getOrNull(prevSelectedWalletIndex)
?: error("Previous selected wallet is not found")
return prevSelectedWallet.title
}
private fun List<UserWallet>.indexOfWallet(id: UserWalletId): Int {
val selectedIndex = indexOfFirst { it.walletId == id }
return if (selectedIndex == -1) {
error("Wallets don't contain a wallet with id: $id")
} else {
selectedIndex
}
}
sealed class Action {
data class InitialWallets(val selectedWalletIndex: Int) : Action()
data class UpdateWalletName(val name: String) : Action()
data class UnlockWallet(
val selectedWalletIndex: Int,
val selectedWallet: UserWallet,
val unlockedWallets: List<UserWallet>,
) : Action()
data class DeleteWallet(
val selectedWalletId: UserWalletId,
val selectedWalletIndex: Int,
val deletedWalletId: UserWalletId,
) : Action()
data class AddWallet(val selectedWalletIndex: Int) : Action()
object Unknown : Action()
}
}