Updated on 2026-08-14

This commit is contained in:
Tangem 2024-07-01 14:07:11 +03:00
parent f6d3003cb0
commit 2d8d9453bd
17 changed files with 763 additions and 55 deletions

1
features/markets/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,18 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("kotlin-parcelize")
id("configuration")
}
android {
namespace = "com.tangem.features.markets.api"
}
dependencies {
implementation(deps.compose.foundation)
/* Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
}

View file

@ -0,0 +1,6 @@
package com.tangem.features.markets
enum class BottomSheetState {
EXPANDED,
COLLAPSED,
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.markets
import androidx.compose.runtime.Stable
@Stable
interface MarketsListComponent {
// TODO
// @Composable
// fun BottomSheetContent(state: State<BottomSheetState>, onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier)
}

1
features/markets/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,24 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.markets.impl"
}
dependencies {
implementation(deps.compose.coil)
implementation(deps.compose.foundation)
implementation(deps.compose.material3)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.ui.utils)
implementation(deps.kotlin.immutable.collections)
implementation(projects.core.ui)
implementation(projects.common.ui)
implementation(projects.common.uiCharts)
}

View file

@ -0,0 +1,448 @@
package com.tangem.features.markets.ui
import android.content.res.Configuration
import androidx.compose.animation.Animatable
import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.interaction.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.IntOffset
import com.tangem.common.ui.charts.MarketChartMini
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.currency.tokenicon.CoinIcon
import com.tangem.core.ui.components.marketprice.PriceChangeInPercent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.windowsize.WindowSizeType
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.ui.models.MarketsListItemModel
import com.tangem.features.markets.ui.preview.MarketChartListItemPreviewDataProvider
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import kotlin.random.Random
internal enum class DragValue { Start, End }
const val SWIPE_THRESHOLD_PERCENT = 0.8f
const val SWIPE_VELOCITY_THRESHOLD = 20f
@Suppress("LongMethod")
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MarketsListItem(
model: MarketsListItemModel,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onSwipeToAction: () -> Unit = {},
) {
val actionWidth = TangemTheme.dimens.size68
val actionWidthPx = with(LocalDensity.current) { actionWidth.toPx() }
val hapticManager = LocalHapticManager.current
val anchors = DraggableAnchors {
DragValue.Start at 0f
DragValue.End at -actionWidthPx
}
val state = remember {
AnchoredDraggableState(
initialValue = DragValue.Start,
anchors = anchors,
positionalThreshold = { it * (1 - SWIPE_THRESHOLD_PERCENT) },
velocityThreshold = { SWIPE_VELOCITY_THRESHOLD },
animationSpec = tween(easing = FastOutSlowInEasing),
confirmValueChange = { it == DragValue.Start },
)
}
val dragInteractionSource = remember { MutableInteractionSource() }
val clickInteractionSource = remember { MutableInteractionSource() }
val isInDraggedState by dragInteractionSource.collectIsDraggedAsState()
LaunchedEffect(Unit) {
var actionPerformed = false
var releasePerformed = true
launch {
snapshotFlow { state.offset }
.collect {
val border = -actionWidthPx * SWIPE_THRESHOLD_PERCENT
if (it < border && actionPerformed.not()) {
hapticManager.vibrateLong()
actionPerformed = true
releasePerformed = false
}
if (it > border) {
if (releasePerformed.not()) {
hapticManager.vibrateShort()
releasePerformed = true
}
actionPerformed = false
}
}
}
launch {
snapshotFlow { isInDraggedState }
.collect {
if (it.not() && actionPerformed) {
releasePerformed = true
onSwipeToAction()
}
}
}
}
Box(
modifier = Modifier
.height(intrinsicSize = IntrinsicSize.Min)
.fillMaxWidth(),
) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.fillMaxHeight()
.offset {
IntOffset(
x = actionWidthPx.roundToInt() +
state
.requireOffset()
.toInt(),
y = 0,
)
}
.width(actionWidth)
.background(TangemTheme.colors.control.checked),
contentAlignment = Alignment.Center,
) {
Image(
modifier = Modifier.size(TangemTheme.dimens.size28),
imageVector = ImageVector.vectorResource(id = R.drawable.ic_plus_mini_28),
colorFilter = ColorFilter.tint(TangemTheme.colors.icon.primary2),
contentDescription = null,
)
}
Box(
modifier = modifier
.align(Alignment.CenterStart)
.clip(RectangleShape)
.offset {
IntOffset(
x = state
.requireOffset()
.toInt(),
y = 0,
)
}
.anchoredDraggable(
state = state,
orientation = Orientation.Horizontal,
interactionSource = dragInteractionSource,
)
.clickable(
enabled = true,
interactionSource = clickInteractionSource,
indication = rememberRipple(),
onClick = onClick,
),
) {
MarketsListItemContent(model = model)
}
}
}
@Composable
private fun MarketsListItemContent(model: MarketsListItemModel, modifier: Modifier = Modifier) {
val windowSize = LocalWindowSize.current
Row(
modifier = modifier.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing15,
),
verticalAlignment = Alignment.CenterVertically,
) {
CoinIcon(
modifier = Modifier.size(TangemTheme.dimens.size36),
url = model.iconUrl,
alpha = 1f,
colorFilter = null,
fallbackResId = R.drawable.ic_custom_token_44,
)
SpacerW12()
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TokenTitle(
modifier = Modifier.weight(1f, fill = false),
name = model.name,
currencySymbol = model.currencySymbol,
)
SpacerW8()
TokenPriceText(
modifier = Modifier.alignByBaseline(),
price = model.price.text,
priceChangeType = model.price.changeType,
)
}
SpacerH(height = TangemTheme.dimens.spacing2)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
TokenSubtitle(
modifier = Modifier
.weight(1f, fill = false)
.alignByBaseline(),
ratingPosition = model.ratingPosition,
marketCap = model.marketCap,
)
PriceChangeInPercent(
modifier = Modifier.alignByBaseline(),
textStyle = TangemTheme.typography.caption2,
type = model.trendType,
valueInPercent = model.trendPercentText,
)
}
}
if (windowSize.widthAtLeast(WindowSizeType.Small)) {
Spacer(Modifier.width(TangemTheme.dimens.spacing10))
Chart(
chartType = model.chartType,
chartRawData = model.chardData,
)
}
}
}
@Composable
private fun TokenTitle(name: String, currencySymbol: String, modifier: Modifier = Modifier) {
Row(modifier = modifier) {
Text(
modifier = Modifier
.weight(1f, fill = false)
.alignByBaseline(),
text = name,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
SpacerW4()
Text(
modifier = Modifier.alignByBaseline(),
text = currencySymbol,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption1,
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
}
@Composable
private fun TokenSubtitle(ratingPosition: String?, marketCap: String?, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
TokenRatingPlace(ratingPosition = ratingPosition)
SpacerW4()
TokenMarketCapText(text = marketCap ?: "")
}
}
@Composable
private fun RowScope.TokenRatingPlace(ratingPosition: String?) {
Box(
modifier = Modifier
.alignByBaseline()
.heightIn(min = TangemTheme.dimens.size16)
.background(
color = TangemTheme.colors.field.primary,
shape = TangemTheme.shapes.roundedCornersSmall2,
)
.padding(horizontal = TangemTheme.dimens.size5), // TODO check
) {
Text(
text = ratingPosition ?: "-",
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption1,
maxLines = 1,
)
}
}
@Composable
private fun RowScope.TokenMarketCapText(text: String) {
Text(
modifier = Modifier.alignByBaseline(),
text = text,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun TokenPriceText(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) {
val growColor = TangemTheme.colors.text.accent
val fallColor = TangemTheme.colors.text.warning
val generalColor = TangemTheme.colors.text.primary1
val color = remember { Animatable(generalColor) }
LaunchedEffect(price, growColor, fallColor, generalColor) {
snapshotFlow { price }
.drop(1)
.distinctUntilChanged()
.collectLatest {
val nextColor = when (priceChangeType) {
PriceChangeType.NEUTRAL,
PriceChangeType.UP,
-> growColor
PriceChangeType.DOWN -> fallColor
null -> generalColor
}
color.animateTo(nextColor, snap())
color.animateTo(generalColor, tween(durationMillis = 500))
}
}
Text(
modifier = modifier,
text = price,
color = color.value,
maxLines = 1,
style = TangemTheme.typography.body2,
overflow = TextOverflow.Visible,
)
}
@Composable
private fun Chart(chartType: MarketChartLook.Type, chartRawData: MarketChartRawData?) {
val chartWidth = TangemTheme.dimens.size56
Box(
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing2)
.size(height = TangemTheme.dimens.size32, width = chartWidth),
) {
if (chartRawData != null) {
MarketChartMini(
rawData = chartRawData,
type = chartType,
)
} else {
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens.size15) // TODO check
.align(Alignment.Center),
)
}
}
}
// region preview
@Preview(showBackground = true, widthDp = 360, name = "normal")
@Preview(showBackground = true, widthDp = 360, name = "normal night", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true, widthDp = 320, name = "small width")
@Composable
private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::class) state: MarketsListItemModel) {
TangemThemePreview {
var state1 by remember { mutableStateOf(state) }
var state2 by remember { mutableStateOf(state) }
var prices by remember {
mutableStateOf(
listOf(
100 to PriceChangeType.NEUTRAL,
200 to PriceChangeType.NEUTRAL,
),
)
}
Column(modifier = Modifier.background(TangemTheme.colors.background.primary)) {
MarketsListItem(
modifier = Modifier,
model = state1,
)
MarketsListItem(
modifier = Modifier,
model = state2,
)
Row {
Button(
onClick = {
state1 = state1.copy(
trendType = PriceChangeType.entries.random(),
)
state2 = state2.copy(
trendType = PriceChangeType.entries.random(),
)
},
) { Text(text = "trend") }
Button(
onClick = {
prices = prices.map {
if (Random.nextBoolean()) {
it.first.inc() to PriceChangeType.UP
} else {
it.first.dec() to PriceChangeType.DOWN
}
}
state1 = state1.copy(
price = MarketsListItemModel.Price(
text = "0.${prices[0].first}023 $",
changeType = prices[0].second,
),
)
state2 = state2.copy(
price = MarketsListItemModel.Price(
text = "0.${prices[1].first}023 $",
changeType = prices[1].second,
),
)
},
) { Text(text = "price") }
}
}
}
}
// endregion preview

View file

@ -0,0 +1,33 @@
package com.tangem.features.markets.ui.models
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.marketprice.PriceChangeType
@Immutable
data class MarketsListItemModel(
val id: String,
val name: String,
val currencySymbol: String,
val iconUrl: String?,
val ratingPosition: String?,
val marketCap: String?,
val price: Price,
val trendPercentText: String,
val trendType: PriceChangeType,
val chardData: MarketChartRawData?,
) {
val chartType: MarketChartLook.Type = when (trendType) {
PriceChangeType.UP,
PriceChangeType.NEUTRAL,
-> MarketChartLook.Type.Growing
PriceChangeType.DOWN -> MarketChartLook.Type.Falling
}
@Immutable
data class Price(
val text: String,
val changeType: PriceChangeType = PriceChangeType.NEUTRAL,
)
}

View file

@ -0,0 +1,94 @@
@file:Suppress("MagicNumber")
package com.tangem.features.markets.ui.preview
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.features.markets.ui.models.MarketsListItemModel
internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvider<MarketsListItemModel>(
collection = listOf(
MarketsListItemModel(
id = "1",
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = "",
ratingPosition = "10",
marketCap = "$6.233 B",
price = MarketsListItemModel.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chardData = MarketChartRawData(
y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f),
),
),
MarketsListItemModel(
id = "1",
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
marketCap = "$6.233 B",
price = MarketsListItemModel.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.NEUTRAL,
chardData = null,
),
MarketsListItemModel(
id = "1",
name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
marketCap = "$6.23348172384781234 B",
price = MarketsListItemModel.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.DOWN,
chardData = MarketChartRawData(
y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f),
),
),
MarketsListItemModel(
id = "1",
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
marketCap = null,
price = MarketsListItemModel.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chardData = MarketChartRawData(
y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f),
),
),
MarketsListItemModel(
id = "1",
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = null,
marketCap = "$6.233 B",
price = MarketsListItemModel.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chardData = MarketChartRawData(
y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f),
),
),
MarketsListItemModel(
id = "1",
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = null,
marketCap = null,
price = MarketsListItemModel.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chardData = MarketChartRawData(
y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f),
),
),
),
)

View file

@ -90,4 +90,5 @@ dependencies {
implementation(projects.features.manageTokens.api)
implementation(projects.features.details.api)
implementation(projects.features.pushNotifications.api)
implementation(projects.features.markets.api)
}