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) repository.getContacts(userWalletId)
} }
val normalizedQuery = query.trim() val normalizedQuery = query.trim()
if (normalizedQuery.isEmpty()) return source return source.map { contacts ->
return source.map { contacts -> contacts.filter { it.matches(normalizedQuery) } } val filtered = if (normalizedQuery.isEmpty()) {
contacts
} else {
contacts.filter { it.matches(normalizedQuery) }
}
filtered.sortedByDescending { it.createdAt }
}
} }
private fun Contact.matches(query: String): Boolean { private fun Contact.matches(query: String): Boolean {

View file

@ -71,6 +71,20 @@ class GetContactsUseCaseTest {
assertThat(result).isEmpty() 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 @Test
fun `GIVEN userWalletId WHEN invoke THEN reads single wallet contacts AND not all contacts`() = runTest { fun `GIVEN userWalletId WHEN invoke THEN reads single wallet contacts AND not all contacts`() = runTest {
// Arrange // Arrange
@ -86,13 +100,17 @@ class GetContactsUseCaseTest {
verify(exactly = 0) { repository.getAllContacts() } 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"), id = ContactId("id-$name"),
walletId = UserWalletId("011"), walletId = UserWalletId("011"),
name = requireNotNull(ContactName(name).getOrNull()), name = requireNotNull(ContactName(name).getOrNull()),
icon = "", icon = "",
iconColor = "KekColor", iconColor = "KekColor",
createdAt = "2026-01-01T00:00:00.000Z", createdAt = createdAt,
updatedAt = "2026-01-01T00:00:00.000Z", updatedAt = "2026-01-01T00:00:00.000Z",
addressEntries = listOf( addressEntries = listOf(
AddressEntry( AddressEntry(

View file

@ -1,19 +1,20 @@
package com.tangem.features.addressbook.addressselector.ui package com.tangem.features.addressbook.addressselector.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll 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.Modifier
import androidx.compose.ui.draw.clip 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountIconUM
@ -64,16 +65,29 @@ internal fun AddressSelectorBottomSheet(
}, },
) )
}, },
content = { AddressSelectorList(contact = contact, onAddressClick = onAddressClick) }, content = {
footer = { val density = LocalDensity.current
var buttonHeight by remember { mutableStateOf(0.dp) }
Box(modifier = Modifier.fillMaxWidth()) {
AddressSelectorList(
contact = contact,
onAddressClick = onAddressClick,
bottomContentPadding = buttonHeight,
)
TangemButton( TangemButton(
onClick = onDismiss, onClick = onDismiss,
text = resourceReference(R.string.common_cancel), text = resourceReference(R.string.common_cancel),
variant = TangemButton.Variant.Secondary, variant = TangemButton.Variant.Secondary,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
.onSizeChanged { size ->
with(density) { buttonHeight = size.height.toDp() }
}
.padding(16.dp), .padding(16.dp),
size = TangemButton.Size.X12,
) )
}
}, },
) )
} }
@ -83,9 +97,12 @@ private fun AddressSelectorList(
contact: MatchedContact, contact: MatchedContact,
onAddressClick: (MatchedContact.ContactAddress) -> Unit, onAddressClick: (MatchedContact.ContactAddress) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
bottomContentPadding: Dp = 0.dp,
) { ) {
Column( Column(
modifier = modifier modifier = modifier
.padding(horizontal = 16.dp)
.padding(bottom = bottomContentPadding)
.background( .background(
color = TangemTheme.colors3.bg.secondary, color = TangemTheme.colors3.bg.secondary,
shape = RoundedCornerShape(20.dp), 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.model.AddressBookListModel
import com.tangem.features.addressbook.list.ui.AddressBookEmptyScreen import com.tangem.features.addressbook.list.ui.AddressBookEmptyScreen
import com.tangem.features.addressbook.list.ui.AddressBookListScreen 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.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.route.AddressBookRoute import com.tangem.features.addressbook.route.AddressBookRoute
@ -50,6 +51,10 @@ internal class DefaultAddressBookListComponent(
val state by model.state.collectAsStateWithLifecycle() val state by model.state.collectAsStateWithLifecycle()
val selector by selectorSlot.subscribeAsState() val selector by selectorSlot.subscribeAsState()
when (val addressBookListUM = state) { when (val addressBookListUM = state) {
is AddressBookListUM.Loading -> AddressBookListShimmer(
onBackClick = router::pop,
modifier = modifier.background(TangemTheme.colors3.bg.primary),
)
is AddressBookListUM.Empty -> AddressBookEmptyScreen( is AddressBookListUM.Empty -> AddressBookEmptyScreen(
onAddContactClick = addressBookListUM.onAddClick, onAddContactClick = addressBookListUM.onAddClick,
onBackClick = router::pop, onBackClick = router::pop,

View file

@ -18,5 +18,5 @@ internal class AddressBookListStateController @Inject constructor() {
uiState.update(function = transformer::transform) 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( is AddressBookListUM.Content -> prevState.copy(
searchBar = prevState.searchBar.copy(query = query, isActive = isActive), 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 @Immutable
internal sealed interface AddressBookListUM { 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 data class Empty(val onAddClick: () -> Unit) : AddressBookListUM
/** /**

View file

@ -27,6 +27,7 @@ import io.mockk.clearMocks
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -58,6 +59,18 @@ internal class AddressBookListModelTest {
model = null 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 @Test
fun `GIVEN default mode AND verified contacts WHEN created THEN content shown`() = runTest { fun `GIVEN default mode AND verified contacts WHEN created THEN content shown`() = runTest {
// Arrange // Arrange

View file

@ -77,6 +77,15 @@ internal fun SendDestinationContent(
onMemoChange = clickIntents::onRecipientMemoValueChange, onMemoChange = clickIntents::onRecipientMemoValueChange,
) )
} }
if (contactsBlock != null && !state.isRecentHidden) {
item(key = "CONTACTS_BLOCK_KEY") {
contactsBlock.Content(
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp),
)
}
}
listHeaderItem( listHeaderItem(
titleRes = if (state.isAccountsMode == true) { titleRes = if (state.isAccountsMode == true) {
R.string.common_accounts 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") { item("SPACER_KEY") {
SpacerH(16.dp) SpacerH(16.dp)
} }