Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-03 11:37:25 +02:00
parent 391e0562cc
commit 12cebc413d
17 changed files with 686 additions and 7 deletions

View file

@ -0,0 +1,34 @@
package com.tangem.features.addressbook.addressinfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.addressbook.addressinfo.model.AddressInfoModel
import com.tangem.features.addressbook.addressinfo.ui.AddressInfoBottomSheet
internal class DefaultAddressInfoComponent(
appComponentContext: AppComponentContext,
private val params: Params,
) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext {
private val model: AddressInfoModel = getOrCreateModel(params)
override fun dismiss() = params.onDismiss()
@Composable
override fun BottomSheet() {
val state by model.state.collectAsStateWithLifecycle()
AddressInfoBottomSheet(state = state, onDismiss = ::dismiss)
}
data class Params(
val address: String,
val networkCount: Int,
val onEditAddress: () -> Unit,
val onDeleteAddress: () -> Unit,
val onDismiss: () -> Unit,
)
}

View file

@ -0,0 +1,43 @@
package com.tangem.features.addressbook.addressinfo.model
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.R
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.features.addressbook.addressinfo.DefaultAddressInfoComponent
import com.tangem.features.addressbook.addressinfo.ui.state.AddressInfoUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@ModelScoped
internal class AddressInfoModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val clipboardManager: ClipboardManager,
private val messageSender: UiMessageSender,
) : Model() {
private val params: DefaultAddressInfoComponent.Params = paramsContainer.require()
val state: StateFlow<AddressInfoUM> = MutableStateFlow(
AddressInfoUM(
address = params.address,
networkCount = params.networkCount,
onCopy = ::onCopy,
onEditAddress = params.onEditAddress,
onDeleteAddress = params.onDeleteAddress,
),
)
private fun onCopy() {
clipboardManager.setText(text = params.address, isSensitive = false)
messageSender.send(SnackbarMessage(resourceReference(R.string.address_book_address_copied)))
params.onDismiss()
}
}

View file

@ -0,0 +1,171 @@
package com.tangem.features.addressbook.addressinfo.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_copy_20
import com.tangem.core.ui.res.generated.icons.ic_cross_24
import com.tangem.features.addressbook.addressinfo.ui.state.AddressInfoUM
import com.tangem.features.addressbook.impl.R
@Composable
internal fun AddressInfoBottomSheet(state: AddressInfoUM, onDismiss: () -> Unit) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
type = TangemBottomSheetType.Modal,
containerColor = TangemTheme.colors3.bg.secondary,
title = {
TangemTopBar(
type = TangemTopBarType.BottomSheet,
endContent = {
TangemButton(
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_cross_24),
onClick = onDismiss,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
},
content = {
AddressInfoContent(
address = state.address,
networkCount = state.networkCount,
onCopy = state.onCopy,
onEditAddress = state.onEditAddress,
onDeleteAddress = state.onDeleteAddress,
)
},
)
}
@Suppress("LongParameterList")
@Composable
private fun AddressInfoContent(
address: String,
networkCount: Int,
onCopy: () -> Unit,
onEditAddress: () -> Unit,
onDeleteAddress: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AddressHeader(address = address, networkCount = networkCount)
SpacerH(8.dp)
TangemButton(
onClick = onCopy,
iconEnd = TangemIconUM.Icon(imageVector = Icons.ic_copy_20),
text = resourceReference(R.string.common_copy_address),
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X9,
)
SpacerH(48.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(CircleShape)
.background(TangemTheme.colors3.bg.opaque.primary)
.clickableSingle(onClick = onDeleteAddress),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Text(
modifier = Modifier.padding(vertical = 14.dp),
text = stringResourceSafe(R.string.address_book_remove_address),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.status.error,
)
}
SpacerH(8.dp)
TangemButton(
modifier = Modifier.fillMaxWidth(),
onClick = onEditAddress,
text = resourceReference(R.string.address_book_edit_address),
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X12,
)
}
}
@Composable
private fun AddressHeader(address: String, networkCount: Int) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemIcon(
tangemIconUM = TangemIconUM.Ident(text = address),
modifier = Modifier
.size(64.dp)
.clip(CircleShape),
)
SpacerH(32.dp)
Text(
text = address,
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
textAlign = TextAlign.Center,
overflow = TextOverflow.MiddleEllipsis,
maxLines = 1,
)
SpacerH(8.dp)
Text(
text = pluralReference(
id = R.plurals.common_networks_count,
count = networkCount,
formatArgs = wrappedList(networkCount),
).resolveReference(),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.secondary,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun Preview_AddressInfoContent() {
TangemThemePreviewRedesign {
AddressInfoContent(
address = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
networkCount = 3,
onCopy = {},
onEditAddress = {},
onDeleteAddress = {},
)
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.addressbook.addressinfo.ui.state
import androidx.compose.runtime.Immutable
@Immutable
internal data class AddressInfoUM(
val address: String,
val networkCount: Int,
val onCopy: () -> Unit,
val onEditAddress: () -> Unit,
val onDeleteAddress: () -> Unit,
)

View file

@ -7,6 +7,7 @@ import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.models.network.Network
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import kotlinx.collections.immutable.toImmutableList
import java.util.UUID
internal class ContactAddressEntriesConverter {
@ -17,6 +18,18 @@ internal class ContactAddressEntriesConverter {
}
}
fun toValidatedAddresses(entries: List<AddressEntry>): List<ValidatedAddress> {
return entries
.groupBy { it.address }
.map { (address, group) ->
ValidatedAddress(
address = address,
networkIds = group.map { it.networkId.value }.toImmutableList(),
memo = group.firstNotNullOfOrNull { it.memo },
)
}
}
private fun ValidatedAddress.toAddressEntry(rawId: String): AddressEntry {
val blockchain = Blockchain.fromNetworkId(rawId)
val hasExtrasSupport = blockchain?.getSupportedTransactionExtras()?.isTxExtrasSupported() == true

View file

@ -0,0 +1,19 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal class RemoveValidatedAddressTransformer(
private val address: String,
private val maxAddresses: Int,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
val addresses = prevState.addresses.filterNot { it.address == address }.toImmutableList()
return prevState.copy(
addresses = addresses,
isAddAddressEnabled = addresses.size < maxAddresses,
)
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal class SetValidatedAddressesTransformer(
private val addresses: List<ValidatedAddress>,
private val maxAddresses: Int,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
return prevState.copy(
addresses = addresses.toImmutableList(),
isAddAddressEnabled = addresses.size < maxAddresses,
)
}
}

View file

@ -0,0 +1,112 @@
package com.tangem.features.addressbook.addressinfo.model
import com.google.common.truth.Truth.assertThat
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.features.addressbook.addressinfo.DefaultAddressInfoComponent
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class AddressInfoModelTest {
private val clipboardManager: ClipboardManager = mockk(relaxed = true)
private val messageSender: UiMessageSender = mockk(relaxed = true)
private var model: AddressInfoModel? = null
@AfterEach
fun tearDown() {
model?.onDestroy()
model = null
}
@Test
fun `WHEN created THEN state reflects params`() = runTest {
// Act
val model = createModel(testScope = this, address = "0xABC", networkCount = 3)
// Assert
assertThat(model.state.value.address).isEqualTo("0xABC")
assertThat(model.state.value.networkCount).isEqualTo(3)
}
@Test
fun `WHEN onCopy THEN address copied AND snackbar shown AND sheet dismissed`() = runTest {
// Arrange
var dismissed = false
val model = createModel(testScope = this, address = "0xABC", onDismiss = { dismissed = true })
// Act
model.state.value.onCopy()
// Assert
verify { clipboardManager.setText(text = "0xABC", isSensitive = false) }
verify { messageSender.send(any<SnackbarMessage>()) }
assertThat(dismissed).isTrue()
}
@Test
fun `WHEN onEditAddress or onDeleteAddress THEN delegated to the editor callbacks`() = runTest {
// Arrange
var edited = false
var deleted = false
val model = createModel(
testScope = this,
onEditAddress = { edited = true },
onDeleteAddress = { deleted = true },
)
// Act
model.state.value.onEditAddress()
model.state.value.onDeleteAddress()
// Assert
assertThat(edited).isTrue()
assertThat(deleted).isTrue()
}
@Suppress("LongParameterList")
private fun createModel(
testScope: TestScope,
address: String = "0xABC",
networkCount: Int = 1,
onEditAddress: () -> Unit = {},
onDeleteAddress: () -> Unit = {},
onDismiss: () -> Unit = {},
): AddressInfoModel {
val params = DefaultAddressInfoComponent.Params(
address = address,
networkCount = networkCount,
onEditAddress = onEditAddress,
onDeleteAddress = onDeleteAddress,
onDismiss = onDismiss,
)
return AddressInfoModel(
paramsContainer = MutableParamsContainer(value = params),
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
clipboardManager = clipboardManager,
messageSender = messageSender,
).also { model = it }
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}