Updated on 2026-08-14
This commit is contained in:
parent
391e0562cc
commit
12cebc413d
17 changed files with 686 additions and 7 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue