Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-01 16:04:01 +05:00
parent 10d6cc7bee
commit 304dfb3205
57 changed files with 180 additions and 155 deletions

View file

@ -1,34 +0,0 @@
package com.tangem.core.ui.components.bottomsheets.chooseaddress
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SimpleSettingsRow
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
@Composable
fun ChooseAddressBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(config) { content: ChooseAddressBottomSheetConfig ->
ChooseAddressBottomSheetContent(content = content)
}
}
@Composable
private fun ChooseAddressBottomSheetContent(content: ChooseAddressBottomSheetConfig) {
Column(
modifier = Modifier.background(TangemTheme.colors.background.primary),
) {
content.addressModels.forEach { addressModel ->
SimpleSettingsRow(
title = addressModel.displayName.resolveReference(),
icon = R.drawable.ic_arrow_top_right_24,
onItemsClick = { content.onClick(addressModel) },
)
}
}
}

View file

@ -1,10 +0,0 @@
package com.tangem.core.ui.components.bottomsheets.chooseaddress
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.tokenreceive.AddressModel
import kotlinx.collections.immutable.ImmutableList
class ChooseAddressBottomSheetConfig(
val addressModels: ImmutableList<AddressModel>,
val onClick: (AddressModel) -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,88 @@
package com.tangem.core.ui.components.bottomsheets.internal
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.key.*
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModalBottomSheetWithBackHandling(
onDismissRequest: () -> Unit,
onBack: (() -> Unit),
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = 0.dp,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets },
content: @Composable ColumnScope.() -> Unit,
) {
BackHandler(enabled = sheetState.targetValue != SheetValue.Hidden) {
onBack()
}
val requester = remember { FocusRequester() }
val backPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current
ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier
.focusRequester(requester)
.focusable()
.onPreviewKeyEvent {
if (it.key == Key.Back && it.type == KeyEventType.KeyUp && !it.nativeKeyEvent.isCanceled) {
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
return@onPreviewKeyEvent true
}
return@onPreviewKeyEvent false
},
sheetState = sheetState,
sheetMaxWidth = sheetMaxWidth,
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
scrimColor = scrimColor,
dragHandle = dragHandle,
contentWindowInsets = contentWindowInsets,
properties = ModalBottomSheetDefaults.properties(
// Set false otherwise the onPreviewKeyEvent doesn't work at all.
// The functionality of shouldDismissOnBackPress is achieved by the BackHandler.
shouldDismissOnBackPress = false,
),
content = content,
)
LaunchedEffect(Unit) {
delay(timeMillis = 200)
requester.requestFocus()
}
}
@OptIn(ExperimentalMaterial3Api::class)
suspend fun SheetState.collapse(onCollapsed: () -> Unit) {
coroutineScope {
launch { hide() }.invokeOnCompletion { onCollapsed() }
}
}

View file

@ -16,7 +16,7 @@ import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.isNullOrEmpty
import com.tangem.core.ui.extensions.resolveReference

View file

@ -1,29 +1,25 @@
package com.tangem.core.ui.components.bottomsheets
package com.tangem.core.ui.components.bottomsheets.sheet
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue.Expanded
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.internal.ModalBottomSheetWithBackHandling
import com.tangem.core.ui.components.bottomsheets.internal.collapse
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.WindowInsetsZero
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Bottom sheet with [content], [titleText] and optional [titleAction].
@ -209,70 +205,4 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicBottomSheet(
content = bsContent,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModalBottomSheetWithBackHandling(
onDismissRequest: () -> Unit,
onBack: (() -> Unit),
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = 0.dp,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets },
content: @Composable ColumnScope.() -> Unit,
) {
BackHandler(enabled = sheetState.targetValue != SheetValue.Hidden) {
onBack()
}
val requester = remember { FocusRequester() }
val backPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current
ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier
.focusRequester(requester)
.focusable()
.onPreviewKeyEvent {
if (it.key == Key.Back && it.type == KeyEventType.KeyUp && !it.nativeKeyEvent.isCanceled) {
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
return@onPreviewKeyEvent true
}
return@onPreviewKeyEvent false
},
sheetState = sheetState,
sheetMaxWidth = sheetMaxWidth,
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
scrimColor = scrimColor,
dragHandle = dragHandle,
contentWindowInsets = contentWindowInsets,
properties = ModalBottomSheetDefaults.properties(
// Set false otherwise the onPreviewKeyEvent doesn't work at all.
// The functionality of shouldDismissOnBackPress is achieved by the BackHandler.
shouldDismissOnBackPress = false,
),
content = content,
)
LaunchedEffect(Unit) {
delay(timeMillis = 200)
requester.requestFocus()
}
}
@OptIn(ExperimentalMaterial3Api::class)
suspend fun SheetState.collapse(onCollapsed: () -> Unit) {
coroutineScope {
launch { hide() }.invokeOnCompletion { onCollapsed() }
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.core.ui.components.bottomsheets
package com.tangem.core.ui.components.bottomsheets.sheet
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box

View file

@ -1,4 +1,4 @@
package com.tangem.core.ui.components.bottomsheets
package com.tangem.core.ui.components.bottomsheets.sheet
import android.content.res.Configuration
import androidx.compose.runtime.Composable

View file

@ -1,43 +0,0 @@
package com.tangem.core.ui.components.bottomsheets.tokenreceive
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.model.NetworkAddress
private const val DSC_ADDRESS_NAME = "DSC"
private const val DEL_ADDRESS_NAME = "Main"
private const val LEGACY_ADDRESS_NAME = "Legacy"
private const val DEFAULT_ADDRESS_NAME = "Default"
fun Set<NetworkAddress.Address>.mapToAddressModels(currency: CryptoCurrency): List<AddressModel> = this
.sortedBy { it.type.ordinal }
.map { address ->
AddressModel(
displayName = currency.network.getAddressDisplayName(address.type),
value = address.value,
type = when (address.type) {
NetworkAddress.Address.Type.Primary -> AddressModel.Type.Default
NetworkAddress.Address.Type.Secondary -> AddressModel.Type.Legacy
},
)
}
private fun Network.getAddressDisplayName(addressType: NetworkAddress.Address.Type): TextReference {
return when (id.value) {
"decimal", "decimal/test" -> {
when (addressType) {
NetworkAddress.Address.Type.Primary -> stringReference(value = DEL_ADDRESS_NAME)
NetworkAddress.Address.Type.Secondary -> stringReference(value = DSC_ADDRESS_NAME)
}
}
else -> {
when (addressType) {
NetworkAddress.Address.Type.Primary -> stringReference(value = DEFAULT_ADDRESS_NAME)
NetworkAddress.Address.Type.Secondary -> stringReference(value = LEGACY_ADDRESS_NAME)
}
}
}
}

View file

@ -1,13 +0,0 @@
package com.tangem.core.ui.components.bottomsheets.tokenreceive
import com.tangem.core.ui.extensions.TextReference
data class AddressModel(
val displayName: TextReference,
val value: String,
val type: Type,
) {
enum class Type {
Legacy, Default
}
}

View file

@ -1,328 +0,0 @@
package com.tangem.core.ui.components.bottomsheets.tokenreceive
import android.content.res.Configuration
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.res.getStringSafe
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SecondaryButtonIconStart
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.components.rememberQrPainters
import com.tangem.core.ui.components.snackbar.CopiedTextSnackbarHost
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
@Composable
fun TokenReceiveBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(config) { content: TokenReceiveBottomSheetConfig ->
TokenReceiveBottomSheetContent(content = content)
}
}
@Composable
private fun TokenReceiveBottomSheetContent(content: TokenReceiveBottomSheetConfig) {
var selectedAddress by remember { mutableStateOf(content.addresses.first()) }
val snackbarHostState = remember(::SnackbarHostState)
ContainerWithSnackbarHost(snackbarHostState = snackbarHostState) {
Column(
modifier = Modifier
.verticalScroll(state = rememberScrollState())
.padding(top = 24.dp, bottom = 16.dp)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(space = 24.dp),
) {
QrCodeContent(content = content, onAddressChange = { selectedAddress = it })
Info(
currency = content.symbol,
network = content.network,
showMemoDisclaimer = content.showMemoDisclaimer,
)
Buttons(
snackbarHostState = snackbarHostState,
onShareClick = { content.onShareClick(selectedAddress.value) },
onCopyClick = { content.onCopyClick(selectedAddress.value) },
)
}
}
}
@Composable
private fun Info(currency: String, network: String, showMemoDisclaimer: Boolean, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(space = 16.dp),
) {
if (showMemoDisclaimer) {
Text(
modifier = Modifier
.padding(horizontal = 18.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.receive_bottom_sheet_no_memo_required_message),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
}
Notification(
config = NotificationConfig(
title = resourceReference(
R.string.receive_bottom_sheet_warning_title,
wrappedList(currency, network),
),
subtitle = resourceReference(R.string.receive_bottom_sheet_warning_message_description),
iconResId = R.drawable.ic_alert_circle_24,
),
iconTint = TangemTheme.colors.icon.accent,
)
}
}
@Composable
private fun ContainerWithSnackbarHost(snackbarHostState: SnackbarHostState, content: @Composable () -> Unit) {
Box {
content()
CopiedTextSnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 80.dp),
)
}
}
@Composable
private fun QrCodeContent(content: TokenReceiveBottomSheetConfig, onAddressChange: (AddressModel) -> Unit) {
val qrCodes = rememberQrPainters(content.addresses.map(AddressModel::value))
val pagerState = rememberPagerState(
initialPage = 0,
initialPageOffsetFraction = 0f,
pageCount = content.addresses::count,
)
LaunchedEffect(key1 = pagerState.currentPage) {
onAddressChange.invoke(content.addresses[pagerState.currentPage])
}
HorizontalPager(state = pagerState) { currentPage ->
QrCodePage(
content = content,
qrCodePainter = qrCodes[currentPage],
currentIndex = currentPage,
)
}
if (pagerState.pageCount > 1) {
val indicatorState = rememberLazyListState()
val selectedColor = TangemTheme.colors.icon.primary1
val unselectedColor = TangemTheme.colors.icon.informative
LazyRow(
modifier = Modifier.height(20.dp),
state = indicatorState,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
repeat(pagerState.pageCount) { iteration ->
item(key = iteration) {
val color by animateColorAsState(
targetValue = if (pagerState.currentPage == iteration) selectedColor else unselectedColor,
label = "",
)
Box(
modifier = Modifier
.padding(horizontal = 4.dp, vertical = 6.dp)
.background(color = color, shape = CircleShape)
.size(7.dp),
)
}
}
}
}
}
@Composable
private fun QrCodePage(content: TokenReceiveBottomSheetConfig, qrCodePainter: Painter, currentIndex: Int) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text(
text = stringResourceSafe(
R.string.receive_bottom_sheet_warning_message,
getName(content = content, index = currentIndex),
content.symbol,
content.network,
),
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
style = TangemTheme.typography.h3,
)
Image(
painter = qrCodePainter,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(248.dp),
)
Text(
text = content.addresses[currentIndex].value,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
style = TangemTheme.typography.subtitle1,
)
}
}
@Composable
private fun getName(content: TokenReceiveBottomSheetConfig, index: Int): String {
return if (content.addresses.size < 2) {
content.name
} else {
"${content.addresses[index].displayName.resolveReference()} ${content.name}"
}
}
@Composable
private fun Buttons(
snackbarHostState: SnackbarHostState,
onShareClick: () -> Unit,
onCopyClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val resources = context.resources
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
SecondaryButtonIconStart(
modifier = Modifier.weight(1f),
text = stringResourceSafe(id = R.string.common_copy),
iconResId = R.drawable.ic_copy_24,
onClick = {
onCopyClick()
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = resources.getStringSafe(R.string.wallet_notification_address_copied),
)
}
},
)
SecondaryButtonIconStart(
modifier = Modifier.weight(1f),
text = stringResourceSafe(id = R.string.common_share),
iconResId = R.drawable.ic_share_24,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onShareClick()
},
)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_TokenReceiveBottomSheet(
@PreviewParameter(TokenReceiveBottomSheetConfigPreviewProvider::class) params: TokenReceiveBottomSheetConfig,
) {
TangemThemePreview {
val config = TangemBottomSheetConfig(
isShown = true,
content = params,
onDismissRequest = {},
)
TokenReceiveBottomSheet(config)
}
}
private class TokenReceiveBottomSheetConfigPreviewProvider : PreviewParameterProvider<TokenReceiveBottomSheetConfig> {
val address = AddressModel(
displayName = stringReference("Address 1"),
value = "0xe5178c7d4d0e861ed2e9414e045b501226b0de8d",
type = AddressModel.Type.Default,
)
private val baseConfig = TokenReceiveBottomSheetConfig(
name = "Stellar",
symbol = "XLM",
network = "Ethereum",
addresses = persistentListOf(address),
showMemoDisclaimer = false,
onCopyClick = {},
onShareClick = {},
)
override val values: Sequence<TokenReceiveBottomSheetConfig>
get() = sequenceOf(
baseConfig,
baseConfig.copy(
addresses = persistentListOf(
address,
address.copy(displayName = stringReference("Address 2")),
),
),
baseConfig.copy(
showMemoDisclaimer = true,
),
baseConfig.copy(
showMemoDisclaimer = true,
addresses = persistentListOf(
address,
address.copy(displayName = stringReference("Address 2")),
),
),
)
}
// endregion Preview

View file

@ -1,14 +0,0 @@
package com.tangem.core.ui.components.bottomsheets.tokenreceive
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import kotlinx.collections.immutable.ImmutableList
data class TokenReceiveBottomSheetConfig(
val name: String,
val symbol: String,
val network: String,
val addresses: ImmutableList<AddressModel>,
val showMemoDisclaimer: Boolean,
val onCopyClick: (String) -> Unit,
val onShareClick: (String) -> Unit,
) : TangemBottomSheetConfigContent