Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-27 21:04:28 +05:00
parent e3d2e7f2e1
commit f25adffe54
12 changed files with 692 additions and 8 deletions

View file

@ -0,0 +1,58 @@
package com.tangem.features.approval.impl
import androidx.compose.runtime.Composable
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.api.GiveApprovalEntryComponent
import com.tangem.features.approval.api.SelectApprovalTypeComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Default implementation of [GiveApprovalEntryComponent].
*
* Picks the concrete child component (full [GiveApprovalComponent] or selection-only
* [SelectApprovalTypeComponent]) at construction time based on
* [GiveApprovalEntryComponent.Params.mode] and delegates [BottomSheet] and [dismiss] to it.
*
* Callers only need to depend on [GiveApprovalEntryComponent.Factory] regardless of the
* underlying mode.
*/
internal class DefaultGiveApprovalEntryComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: GiveApprovalEntryComponent.Params,
giveApprovalComponentFactory: GiveApprovalComponent.Factory,
selectApprovalTypeComponentFactory: SelectApprovalTypeComponent.Factory,
) : GiveApprovalEntryComponent, AppComponentContext by appComponentContext {
private val delegate: ComposableBottomSheetComponent = when (val mode = params.mode) {
is GiveApprovalEntryComponent.Mode.FullApproval -> giveApprovalComponentFactory.create(
context = child("giveApprovalEntry_full"),
params = mode.params,
)
is GiveApprovalEntryComponent.Mode.SelectOnly -> selectApprovalTypeComponentFactory.create(
context = child("giveApprovalEntry_select"),
params = mode.params,
)
}
override fun dismiss() {
delegate.dismiss()
}
@Composable
override fun BottomSheet() {
delegate.BottomSheet()
}
@AssistedFactory
interface Factory : GiveApprovalEntryComponent.Factory {
override fun create(
context: AppComponentContext,
params: GiveApprovalEntryComponent.Params,
): DefaultGiveApprovalEntryComponent
}
}

View file

@ -0,0 +1,80 @@
package com.tangem.features.approval.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.R
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.sheet.TangemBottomSheet
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.approval.api.SelectApprovalTypeComponent
import com.tangem.features.approval.impl.model.SelectApprovalTypeModel
import com.tangem.features.approval.impl.ui.SelectApprovalTypeContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Default implementation of [SelectApprovalTypeComponent].
*
* Renders the same selection UI as the full [com.tangem.features.approval.api.GiveApprovalComponent]
* but without the fee selector block and without dispatching the on-chain approval transaction.
* Dismissing the bottom sheet (close button or external dismiss) is treated as a cancel.
*/
internal class DefaultSelectApprovalTypeComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SelectApprovalTypeComponent.Params,
) : SelectApprovalTypeComponent, AppComponentContext by appComponentContext {
private val model: SelectApprovalTypeModel = getOrCreateModel(params = params)
private val currency: String = params.cryptoCurrencyStatus.currency.symbol
override fun dismiss() {
params.callback.onCancelClick()
}
@Composable
override fun BottomSheet() {
val uiState by model.uiState.collectAsStateWithLifecycle()
val config = remember {
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
)
}
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
titleText = resourceReference(R.string.give_permission_title),
titleAction = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_close_new_20,
onClicked = model::onCancelClick,
),
) {
SelectApprovalTypeContent(
currency = currency,
uiState = uiState,
onChangeApproveType = model::onChangeApproveType,
onConfirmClick = model::onConfirmClick,
)
}
}
@AssistedFactory
interface Factory : SelectApprovalTypeComponent.Factory {
override fun create(
context: AppComponentContext,
params: SelectApprovalTypeComponent.Params,
): DefaultSelectApprovalTypeComponent
}
}

View file

@ -3,10 +3,15 @@ package com.tangem.features.approval.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.api.GiveApprovalEntryComponent
import com.tangem.features.approval.api.GiveApprovalFeatureToggles
import com.tangem.features.approval.api.SelectApprovalTypeComponent
import com.tangem.features.approval.impl.DefaultGiveApprovalComponent
import com.tangem.features.approval.impl.DefaultGiveApprovalEntryComponent
import com.tangem.features.approval.impl.DefaultGiveApprovalFeatureToggles
import com.tangem.features.approval.impl.DefaultSelectApprovalTypeComponent
import com.tangem.features.approval.impl.model.GiveApprovalModel
import com.tangem.features.approval.impl.model.SelectApprovalTypeModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -26,6 +31,18 @@ internal interface GiveApprovalFeatureModule {
@Binds
@Singleton
fun bindComponentFactory(factory: DefaultGiveApprovalComponent.Factory): GiveApprovalComponent.Factory
@Binds
@Singleton
fun bindSelectApprovalTypeComponentFactory(
factory: DefaultSelectApprovalTypeComponent.Factory,
): SelectApprovalTypeComponent.Factory
@Binds
@Singleton
fun bindGiveApprovalEntryComponentFactory(
factory: DefaultGiveApprovalEntryComponent.Factory,
): GiveApprovalEntryComponent.Factory
}
@Module
@ -36,4 +53,9 @@ internal interface GiveApprovalModelModule {
@IntoMap
@ClassKey(GiveApprovalModel::class)
fun bindModel(model: GiveApprovalModel): Model
@Binds
@IntoMap
@ClassKey(SelectApprovalTypeModel::class)
fun bindSelectApprovalTypeModel(model: SelectApprovalTypeModel): Model
}

View file

@ -0,0 +1,52 @@
package com.tangem.features.approval.impl.model
import androidx.compose.runtime.Stable
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.approval.api.SelectApprovalTypeComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
/**
* Model for [SelectApprovalTypeComponent].
*
* Keeps the currently selected [ApproveType] and exposes intents to change it, open the
* learn-more URL, confirm the selection, and cancel. Unlike [GiveApprovalModel] this model
* does NOT load fees or submit any transaction confirmation simply notifies the caller
* via the params callback with the selected [ApproveType].
*/
@Stable
@ModelScoped
internal class SelectApprovalTypeModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
) : Model() {
private val params: SelectApprovalTypeComponent.Params = paramsContainer.require()
val uiState: StateFlow<SelectApprovalTypeUM>
field = MutableStateFlow(
SelectApprovalTypeUM(
approveType = params.initialApproveType,
subtitle = params.amountFooter,
),
)
fun onChangeApproveType(approveType: ApproveType) {
if (uiState.value.approveType == approveType) return
uiState.update { it.copy(approveType = approveType) }
}
fun onConfirmClick() {
params.callback.onApproveTypeSelected(uiState.value.approveType)
}
fun onCancelClick() {
params.callback.onCancelClick()
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.approval.impl.model
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
internal data class SelectApprovalTypeUM(
val subtitle: TextReference,
val approveType: ApproveType,
val approveItems: ImmutableList<ApproveType> = ApproveType.entries.toImmutableList(),
)

View file

@ -0,0 +1,183 @@
package com.tangem.features.approval.impl.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
import androidx.compose.material3.Text as M3Text
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import kotlinx.collections.immutable.ImmutableList
/**
* Reusable row that shows "Amount for {currency}" on the left and the currently selected
* [ApproveType] on the right, with a dropdown to switch between the available types.
*
* Used by both [GiveApprovalContent] (full approval flow) and [SelectApprovalTypeContent]
* (selection-only flow).
*/
@Composable
internal fun ApprovalTypeSelectorRow(
currency: String,
approveType: ApproveType,
approveItems: ImmutableList<ApproveType>,
onChangeApproveType: (ApproveType) -> Unit,
modifier: Modifier = Modifier,
) {
var isExpandSelector by remember { mutableStateOf(false) }
var amountSize by remember { mutableStateOf(IntSize.Zero) }
Box(
modifier = modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = { isExpandSelector = true },
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { amountSize = it }
.padding(vertical = 12.dp, horizontal = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
M3Text(
text = stringResourceSafe(id = R.string.give_permission_rows_amount, currency),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
maxLines = 1,
)
SpacerWMax()
M3Text(
text = approveType.text.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body1,
maxLines = 1,
)
Icon(
painter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.ic_chevron_24)),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing2),
)
}
ApprovalTypeDropdown(
isExpanded = isExpandSelector,
onDismiss = { isExpandSelector = false },
onItemClick = { type ->
isExpandSelector = false
onChangeApproveType(type)
},
items = approveItems,
selectedType = approveType,
amountSize = amountSize,
)
}
}
@Suppress("LongParameterList")
@Composable
private fun ApprovalTypeDropdown(
isExpanded: Boolean,
onDismiss: () -> Unit,
onItemClick: (ApproveType) -> Unit,
items: ImmutableList<ApproveType>,
selectedType: ApproveType,
amountSize: IntSize,
) {
var dropDownWidth by remember { mutableStateOf(IntSize.Zero) }
val offsetY = amountSize.height.times(-1)
val offsetX = amountSize.width - dropDownWidth.width
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(surface = TangemTheme.colors.background.action),
shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(TangemTheme.dimens.radius16)),
) {
DropdownMenu(
expanded = isExpanded,
onDismissRequest = onDismiss,
properties = PopupProperties(clippingEnabled = false),
offset = with(LocalDensity.current) {
DpOffset(x = offsetX.toDp(), y = offsetY.toDp())
},
modifier = Modifier
.wrapContentSize()
.background(TangemTheme.colors.background.action)
.onSizeChanged { dropDownWidth = it },
) {
items.forEach { item ->
val color = if (item == selectedType) TangemTheme.colors.icon.accent else Color.Transparent
DropdownMenuItem(
modifier = Modifier.fillMaxWidth(),
text = {
Row {
M3Text(
text = when (item) {
ApproveType.LIMITED -> stringResourceSafe(
id = R.string.give_permission_current_transaction,
)
ApproveType.UNLIMITED -> stringResourceSafe(
id = R.string.give_permission_unlimited,
)
},
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body1,
maxLines = 1,
)
SpacerWMax()
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(id = R.drawable.ic_check_24),
),
tint = color,
contentDescription = null,
modifier = Modifier.padding(start = TangemTheme.dimens.size20),
)
}
},
onClick = {
onItemClick.invoke(item)
},
)
}
}
}
}

View file

@ -0,0 +1,157 @@
package com.tangem.features.approval.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.ui.R
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH18
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.approval.impl.model.SelectApprovalTypeUM
import kotlinx.collections.immutable.persistentListOf
/**
* UI for the selection-only approval variant. Reuses [ApprovalTypeSelectorRow] for the
* approval-type picker. The primary button calls [onConfirmClick] which is wired to a
* callback that returns the chosen [ApproveType]
* to the caller (instead of submitting an on-chain transaction).
*/
@Composable
@Suppress("LongParameterList")
internal fun SelectApprovalTypeContent(
currency: String,
uiState: SelectApprovalTypeUM,
onChangeApproveType: (ApproveType) -> Unit,
onConfirmClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = uiState.subtitle.resolveAnnotatedReference(),
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
textAlign = TextAlign.Center,
modifier = Modifier.padding(
top = 2.dp,
start = 16.dp,
end = 16.dp,
),
)
SpacerH18()
ApprovalTypeSelectorRow(
currency = currency,
approveType = uiState.approveType,
approveItems = uiState.approveItems,
onChangeApproveType = onChangeApproveType,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
)
SpacerH(height = TangemTheme.dimens.spacing20)
PrimaryButton(
text = stringResourceSafe(id = R.string.common_continue),
onClick = onConfirmClick,
enabled = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
)
SpacerH16()
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun SelectApprovalTypeContentPreview(
@PreviewParameter(SelectApprovalTypeContentPreviewProvider::class) params: SelectApprovalTypePreviewParams,
) {
TangemThemePreview {
SelectApprovalTypeContent(
currency = params.currency,
uiState = params.uiState,
onChangeApproveType = {},
onConfirmClick = {},
)
}
}
private data class SelectApprovalTypePreviewParams(
val currency: String,
val uiState: SelectApprovalTypeUM,
)
private class SelectApprovalTypeContentPreviewProvider : PreviewParameterProvider<SelectApprovalTypePreviewParams> {
override val values: Sequence<SelectApprovalTypePreviewParams>
get() = sequenceOf(
SelectApprovalTypePreviewParams(
currency = "USDT",
uiState = SelectApprovalTypeUM(
subtitle = combinedReference(
resourceReference(
id = R.string.give_permission_swap_subtitle_v2,
// Arg is only used in iOS
formatArgs = wrappedList(""),
),
styledResourceReference(
id = R.string.common_learn_more,
spanStyleReference = {
TangemTheme.typography.caption2
.copy(color = TangemTheme.colors.text.accent)
.toSpanStyle()
},
onClick = { },
),
),
approveType = ApproveType.LIMITED,
approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED),
),
),
SelectApprovalTypePreviewParams(
currency = "USDC",
uiState = SelectApprovalTypeUM(
subtitle = combinedReference(
resourceReference(
id = com.tangem.common.ui.R.string.give_permission_swap_subtitle_v2,
// Arg is only used in iOS
formatArgs = wrappedList(""),
),
styledResourceReference(
id = com.tangem.common.ui.R.string.common_learn_more,
spanStyleReference = {
TangemTheme.typography.caption2
.copy(color = TangemTheme.colors.text.accent)
.toSpanStyle()
},
onClick = {},
),
),
approveType = ApproveType.UNLIMITED,
approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED),
),
),
)
}
// endregion