From 2494a5c4e68587b62f7c5ed7d0dd4de9d18535b2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 2 Jul 2026 14:41:00 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../addressbook/usecase/GetContactsUseCase.kt | 10 ++- .../usecase/GetContactsUseCaseTest.kt | 22 ++++- .../ui/AddressSelectorBottomSheet.kt | 47 ++++++---- .../list/DefaultAddressBookListComponent.kt | 5 ++ .../state/AddressBookListStateController.kt | 2 +- .../UpdateAddressBookListQueryTransformer.kt | 4 +- .../list/ui/AddressBookListShimmer.kt | 89 +++++++++++++++++++ .../list/ui/state/AddressBookListUM.kt | 3 + .../list/model/AddressBookListModelTest.kt | 13 +++ .../destination/ui/SendDestinationContent.kt | 18 ++-- 10 files changed, 183 insertions(+), 30 deletions(-) create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListShimmer.kt diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt index acf8409dc1..381a49a114 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt @@ -17,8 +17,14 @@ class GetContactsUseCase( repository.getContacts(userWalletId) } val normalizedQuery = query.trim() - if (normalizedQuery.isEmpty()) return source - return source.map { contacts -> contacts.filter { it.matches(normalizedQuery) } } + return source.map { contacts -> + val filtered = if (normalizedQuery.isEmpty()) { + contacts + } else { + contacts.filter { it.matches(normalizedQuery) } + } + filtered.sortedByDescending { it.createdAt } + } } private fun Contact.matches(query: String): Boolean { diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt index 21ce51f700..0d0e80cfb5 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt @@ -71,6 +71,20 @@ class GetContactsUseCaseTest { assertThat(result).isEmpty() } + @Test + fun `GIVEN contacts with different createdAt WHEN invoke THEN sorted newest first`() = runTest { + // Arrange + val older = contact(name = "Older", address = "0x1", createdAt = "2026-01-01T00:00:00.000Z") + val newer = contact(name = "Newer", address = "0x2", createdAt = "2026-06-01T00:00:00.000Z") + every { repository.getAllContacts() } returns flowOf(listOf(older, newer)) + + // Act + val result = useCase(query = "").first() + + // Assert + assertThat(result).containsExactly(newer, older).inOrder() + } + @Test fun `GIVEN userWalletId WHEN invoke THEN reads single wallet contacts AND not all contacts`() = runTest { // Arrange @@ -86,13 +100,17 @@ class GetContactsUseCaseTest { verify(exactly = 0) { repository.getAllContacts() } } - private fun contact(name: String, address: String): Contact = Contact( + private fun contact( + name: String, + address: String, + createdAt: String = "2026-01-01T00:00:00.000Z", + ): Contact = Contact( id = ContactId("id-$name"), walletId = UserWalletId("011"), name = requireNotNull(ContactName(name).getOrNull()), icon = "", iconColor = "KekColor", - createdAt = "2026-01-01T00:00:00.000Z", + createdAt = createdAt, updatedAt = "2026-01-01T00:00:00.000Z", addressEntries = listOf( AddressEntry( diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt index 60ea57de1e..e4b3437e51 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt @@ -1,19 +1,20 @@ package com.tangem.features.addressbook.addressselector.ui import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import com.tangem.common.ui.account.AccountIconUM @@ -64,16 +65,29 @@ internal fun AddressSelectorBottomSheet( }, ) }, - content = { AddressSelectorList(contact = contact, onAddressClick = onAddressClick) }, - footer = { - TangemButton( - onClick = onDismiss, - text = resourceReference(R.string.common_cancel), - variant = TangemButton.Variant.Secondary, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) + content = { + val density = LocalDensity.current + var buttonHeight by remember { mutableStateOf(0.dp) } + Box(modifier = Modifier.fillMaxWidth()) { + AddressSelectorList( + contact = contact, + onAddressClick = onAddressClick, + bottomContentPadding = buttonHeight, + ) + TangemButton( + onClick = onDismiss, + text = resourceReference(R.string.common_cancel), + variant = TangemButton.Variant.Secondary, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .onSizeChanged { size -> + with(density) { buttonHeight = size.height.toDp() } + } + .padding(16.dp), + size = TangemButton.Size.X12, + ) + } }, ) } @@ -83,9 +97,12 @@ private fun AddressSelectorList( contact: MatchedContact, onAddressClick: (MatchedContact.ContactAddress) -> Unit, modifier: Modifier = Modifier, + bottomContentPadding: Dp = 0.dp, ) { Column( modifier = modifier + .padding(horizontal = 16.dp) + .padding(bottom = bottomContentPadding) .background( color = TangemTheme.colors3.bg.secondary, shape = RoundedCornerShape(20.dp), diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt index 4f02d5303e..7b5ba6c1bb 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt @@ -17,6 +17,7 @@ import com.tangem.features.addressbook.AddressSelectorComponent import com.tangem.features.addressbook.list.model.AddressBookListModel import com.tangem.features.addressbook.list.ui.AddressBookEmptyScreen import com.tangem.features.addressbook.list.ui.AddressBookListScreen +import com.tangem.features.addressbook.list.ui.AddressBookListShimmer import com.tangem.features.addressbook.list.ui.state.AddressBookListUM import com.tangem.features.addressbook.route.AddressBookRoute @@ -50,6 +51,10 @@ internal class DefaultAddressBookListComponent( val state by model.state.collectAsStateWithLifecycle() val selector by selectorSlot.subscribeAsState() when (val addressBookListUM = state) { + is AddressBookListUM.Loading -> AddressBookListShimmer( + onBackClick = router::pop, + modifier = modifier.background(TangemTheme.colors3.bg.primary), + ) is AddressBookListUM.Empty -> AddressBookEmptyScreen( onAddContactClick = addressBookListUM.onAddClick, onBackClick = router::pop, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt index 8b7799c8f9..6b63ff65fb 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt @@ -18,5 +18,5 @@ internal class AddressBookListStateController @Inject constructor() { uiState.update(function = transformer::transform) } - private fun getInitialState(): AddressBookListUM = AddressBookListUM.Empty(onAddClick = {}) + private fun getInitialState(): AddressBookListUM = AddressBookListUM.Loading } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt index b78a450a7b..4b652b6119 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt @@ -12,6 +12,8 @@ internal class UpdateAddressBookListQueryTransformer( is AddressBookListUM.Content -> prevState.copy( searchBar = prevState.searchBar.copy(query = query, isActive = isActive), ) - is AddressBookListUM.Empty -> prevState + is AddressBookListUM.Empty, + AddressBookListUM.Loading, + -> prevState } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListShimmer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListShimmer.kt new file mode 100644 index 0000000000..3b4ab5d208 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListShimmer.kt @@ -0,0 +1,89 @@ +package com.tangem.features.addressbook.list.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.shimmers.ProvideTangemShimmer +import com.tangem.core.ui.ds2.shimmers.RectangleShimmer +import com.tangem.core.ui.extensions.resourceReference +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_chevron_left_20 + +@Composable +internal fun AddressBookListShimmer(onBackClick: () -> Unit, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxSize()) { + TangemTopBar( + modifier = Modifier.statusBarsPadding(), + title = resourceReference(R.string.address_book_title), + startContent = { + TangemButton( + iconStart = TangemIconUM.Icon(imageVector = Icons.ic_chevron_left_20), + onClick = onBackClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + ) + ProvideTangemShimmer { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 12.dp) + .background( + color = TangemTheme.colors3.bg.secondary, + shape = RoundedCornerShape(24.dp), + ), + ) { + repeat(SHIMMER_ROW_COUNT) { ContactRowShimmer() } + } + } + } +} + +@Composable +private fun ContactRowShimmer() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RectangleShimmer( + modifier = Modifier.size(40.dp), + radius = 32.dp, + ) + Column( + modifier = Modifier.padding(start = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + RectangleShimmer(modifier = Modifier.size(width = 140.dp, height = 16.dp)) + RectangleShimmer(modifier = Modifier.size(width = 90.dp, height = 12.dp)) + } + } +} + +private const val SHIMMER_ROW_COUNT = 3 + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_AddressBookListShimmer() { + TangemThemePreviewRedesign { + AddressBookListShimmer( + onBackClick = {}, + modifier = Modifier.background(TangemTheme.colors3.bg.primary), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt index 08a4cbdfe2..8adb2c2cc5 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt @@ -12,6 +12,9 @@ import kotlinx.collections.immutable.ImmutableList @Immutable internal sealed interface AddressBookListUM { + /** Initial state while the address books are being (re-)synced on open — rendered as shimmer placeholders. */ + data object Loading : AddressBookListUM + data class Empty(val onAddClick: () -> Unit) : AddressBookListUM /** diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt index 454ab14316..b51e0e9832 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt @@ -27,6 +27,7 @@ import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -58,6 +59,18 @@ internal class AddressBookListModelTest { model = null } + @Test + fun `GIVEN feature just opened WHEN contacts not yet loaded THEN Loading state`() = runTest { + // Arrange — the interactor has not emitted yet (books still syncing). + every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns emptyFlow() + + // Act + val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default) + + // Assert — shimmer placeholder until the first emission arrives. + assertThat(model.state.value).isEqualTo(AddressBookListUM.Loading) + } + @Test fun `GIVEN default mode AND verified contacts WHEN created THEN content shown`() = runTest { // Arrange diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt index 84f2269749..13923455a4 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt @@ -77,6 +77,15 @@ internal fun SendDestinationContent( onMemoChange = clickIntents::onRecipientMemoValueChange, ) } + if (contactsBlock != null && !state.isRecentHidden) { + item(key = "CONTACTS_BLOCK_KEY") { + contactsBlock.Content( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp), + ) + } + } listHeaderItem( titleRes = if (state.isAccountsMode == true) { R.string.common_accounts @@ -117,15 +126,6 @@ internal fun SendDestinationContent( ) }, ) - if (contactsBlock != null && !state.isRecentHidden) { - item(key = "CONTACTS_BLOCK_KEY") { - contactsBlock.Content( - modifier = Modifier - .fillMaxWidth() - .padding(top = 20.dp), - ) - } - } item("SPACER_KEY") { SpacerH(16.dp) }