Updated on 2026-08-14
This commit is contained in:
parent
d396037399
commit
2494a5c4e6
10 changed files with 183 additions and 30 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue