Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-02 14:41:00 +02:00
parent d396037399
commit 2494a5c4e6
10 changed files with 183 additions and 30 deletions

View file

@ -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 {

View file

@ -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(

View file

@ -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),

View file

@ -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,

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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),
)
}
}

View file

@ -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
/**

View file

@ -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

View file

@ -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)
}