Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-08 11:17:16 +02:00
parent a43f322c61
commit 332927dc9b
11 changed files with 404 additions and 12 deletions

View file

@ -105,6 +105,7 @@
<item quantity="other">%d addresses</item>
</plurals>
<string name="address_book_choose_address">Choose address</string>
<string name="address_book_clear_all_networks">Clear All</string>
<string name="address_book_contact">Contact</string>
<string name="address_book_contact_name">Contact name</string>
<string name="address_book_copy_address">Copy address</string>
@ -134,6 +135,7 @@
<string name="address_book_save_to_wallet_title">Save to Wallet</string>
<string name="address_book_save_wallet_to_description">This contact will be linked to this wallets address book.</string>
<string name="address_book_search_no_results">No results found.\nTry another name</string>
<string name="address_book_select_all_networks">Select All</string>
<string name="address_book_select_network">Select network</string>
<string name="address_book_title">Address book</string>
<string name="address_book_unsaved_changes">Unsaved changes</string>

View file

@ -11,6 +11,7 @@ private const val WALLET_ID = "Wallet Id"
private const val CONTACT_ID = "Contact Id"
private const val MODE = "Mode"
private const val CONTACTS_COUNT = "Contacts Count"
private const val ACTION = "Action"
sealed class AddressBookEvents(
event: String,
@ -162,6 +163,24 @@ sealed class AddressBookEvents(
)
// endregion
// region Choose network
class SelectAllNetworksTapped(
walletId: UserWalletId,
action: Action,
) : AddressBookEvents(
event = "Select All Networks Tapped",
params = mapOf(
WALLET_ID to walletId.stringValue,
ACTION to action.value,
),
) {
enum class Action(val value: String) {
SelectAll("Select All"),
ClearAll("Clear All"),
}
}
// endregion
// region Deletion
class AddressRemoved(
walletId: UserWalletId,

View file

@ -88,6 +88,14 @@ internal class AddressBookAnalyticsSender @Inject constructor(
)
}
fun sendSelectAllNetworksTapped(action: AddressBookEvents.SelectAllNetworksTapped.Action, scope: CoroutineScope) {
scope.launch(dispatcherProvider.default) {
analyticsEventHandler.send(
AddressBookEvents.SelectAllNetworksTapped(walletId = selectedWalletId(), action = action),
)
}
}
fun sendAddContactTapped(fromSendSuccess: Boolean, scope: CoroutineScope) {
scope.launch(dispatcherProvider.default) {
analyticsEventHandler.send(

View file

@ -6,6 +6,8 @@ import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.addressbook.analytics.AddressBookEvents
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent
import com.tangem.features.addressbook.selectnetworks.state.SelectNetworksStateController
import com.tangem.features.addressbook.selectnetworks.state.transformers.UpdateNetworksContentTransformer
@ -22,6 +24,7 @@ internal class SelectNetworksModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val stateController: SelectNetworksStateController,
private val analyticsSender: AddressBookAnalyticsSender,
) : Model() {
private val params: DefaultSelectNetworksComponent.Params = paramsContainer.require()
@ -50,16 +53,18 @@ internal class SelectNetworksModel @Inject constructor(
onActiveChange = ::onActiveChange,
onBackClick = params.onBackClick,
onDoneClick = ::onDoneClick,
onSelectAllClick = ::onSelectAllClick,
),
)
}
private fun subscribeToContent() {
combine(query, selectedNetworks) { query, selection ->
combine(query, selectedNetworks, isSearchActive) { query, selection, isSearchActive ->
UpdateNetworksContentTransformer(
matchedBlockchains = matchedBlockchains,
query = query,
selectedNetworkIds = selection,
isSearchActive = isSearchActive,
onToggle = ::onToggle,
)
}
@ -88,6 +93,22 @@ internal class SelectNetworksModel @Inject constructor(
selectedNetworks.value = if (networkId in current) current - networkId else current + networkId
}
private fun onSelectAllClick() {
val allNetworkIds = matchedBlockchains.map { blockchain -> blockchain.toNetworkId() }.toSet()
val isSelectingAll = allNetworkIds.isEmpty() || !selectedNetworks.value.containsAll(allNetworkIds)
analyticsSender.sendSelectAllNetworksTapped(
action = if (isSelectingAll) {
AddressBookEvents.SelectAllNetworksTapped.Action.SelectAll
} else {
AddressBookEvents.SelectAllNetworksTapped.Action.ClearAll
},
scope = modelScope,
)
selectedNetworks.value = if (isSelectingAll) allNetworkIds else emptySet()
}
private fun onDoneClick() {
val selected = selectedNetworks.value
if (selected.isEmpty()) return

View file

@ -36,6 +36,7 @@ internal class SelectNetworksStateController @Inject constructor() {
onCloseClick = {},
),
networks = persistentListOf(),
selectAllButton = SelectNetworksUM.SelectAllButtonUM.Empty,
doneButton = TangemButtonUM(
text = TextReference.Res(R.string.common_done),
type = TangemButtonType.Primary,
@ -43,5 +44,6 @@ internal class SelectNetworksStateController @Inject constructor() {
onClick = {},
),
onBackClick = {},
onSelectAllClick = {},
)
}

View file

@ -11,6 +11,7 @@ internal class UpdateNetworksContentTransformer(
private val matchedBlockchains: List<Blockchain>,
private val query: String,
private val selectedNetworkIds: Set<String>,
private val isSearchActive: Boolean,
private val onToggle: (networkId: String) -> Unit,
) : Transformer<SelectNetworksUM> {
@ -36,9 +37,18 @@ internal class UpdateNetworksContentTransformer(
}
.toImmutableList()
val isAllSelected = matchedBlockchains.isNotEmpty() &&
selectedNetworkIds.size == matchedBlockchains.size
val selectAllButton = when {
isSearchActive -> SelectNetworksUM.SelectAllButtonUM.Empty
isAllSelected -> SelectNetworksUM.SelectAllButtonUM.ClearAll
else -> SelectNetworksUM.SelectAllButtonUM.SelectAll
}
// Search field is owned by UpdateSelectNetworksSearchBarTransformer and intentionally left untouched here.
return prevState.copy(
networks = networks,
selectAllButton = selectAllButton,
doneButton = prevState.doneButton.copy(isEnabled = selectedNetworkIds.isNotEmpty()),
)
}

View file

@ -8,6 +8,7 @@ internal class UpdateSelectNetworksInitialStateTransformer(
private val onActiveChange: (Boolean) -> Unit,
private val onBackClick: () -> Unit,
private val onDoneClick: () -> Unit,
private val onSelectAllClick: () -> Unit,
) : Transformer<SelectNetworksUM> {
override fun transform(prevState: SelectNetworksUM): SelectNetworksUM {
@ -20,6 +21,7 @@ internal class UpdateSelectNetworksInitialStateTransformer(
),
doneButton = prevState.doneButton.copy(onClick = onDoneClick),
onBackClick = onBackClick,
onSelectAllClick = onSelectAllClick,
)
}
}

View file

@ -1,8 +1,15 @@
package com.tangem.features.addressbook.selectnetworks.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@ -10,6 +17,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -25,10 +33,9 @@ import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.checkbox.TangemCheckmark
import com.tangem.core.ui.ds2.search.TangemSearch
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM
@ -79,11 +86,9 @@ internal fun SelectNetworksContent(state: SelectNetworksUM, modifier: Modifier =
contentPadding = PaddingValues(horizontal = 16.dp),
) {
item {
Text(
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
text = stringResourceSafe(R.string.common_available_networks),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
NetworksHeader(
selectAllButton = state.selectAllButton,
onSelectAllClick = state.onSelectAllClick,
)
}
items(items = state.networks, key = NetworkItemUM::id) { item ->
@ -105,12 +110,64 @@ internal fun SelectNetworksContent(state: SelectNetworksUM, modifier: Modifier =
}
}
@Composable
private fun NetworksHeader(
selectAllButton: SelectNetworksUM.SelectAllButtonUM,
onSelectAllClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val hapticManager = LocalHapticManager.current
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier
.weight(1f)
.padding(top = 16.dp, bottom = 4.dp),
text = stringResourceSafe(R.string.common_available_networks),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
)
AnimatedContent(
targetState = selectAllButton,
transitionSpec = {
fadeIn(animationSpec = tween(durationMillis = 400)) togetherWith
fadeOut(animationSpec = tween(durationMillis = 400))
},
label = "SelectAllButton",
) { button ->
if (!button.text.isNullOrEmpty()) {
Text(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {
hapticManager.perform(TangemHapticEffect.View.SegmentTick)
onSelectAllClick()
},
)
.padding(start = 4.dp, top = 16.dp, bottom = 4.dp),
text = button.text.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.brand,
)
}
}
}
}
@Composable
private fun NetworkRow(item: NetworkItemUM) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickableSingle(onClick = item.onCheckedChange)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = item.onCheckedChange,
)
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@ -182,6 +239,7 @@ private fun Preview_SelectNetworksContent() {
onCheckedChange = {},
),
),
selectAllButton = SelectNetworksUM.SelectAllButtonUM.SelectAll,
doneButton = TangemButtonUM(
text = TextReference.Res(R.string.common_done),
type = TangemButtonType.Primary,
@ -189,6 +247,7 @@ private fun Preview_SelectNetworksContent() {
onClick = {},
),
onBackClick = {},
onSelectAllClick = {},
),
)
}

View file

@ -2,16 +2,21 @@ package com.tangem.features.addressbook.selectnetworks.ui.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds2.search.TangemSearch
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class SelectNetworksUM(
val searchBar: TangemSearch.State,
val networks: ImmutableList<NetworkItemUM>,
val selectAllButton: SelectAllButtonUM,
val doneButton: TangemButtonUM,
val onBackClick: () -> Unit,
val onSelectAllClick: () -> Unit,
) {
@Immutable
@ -23,4 +28,10 @@ internal data class SelectNetworksUM(
val isSelected: Boolean,
val onCheckedChange: () -> Unit,
)
enum class SelectAllButtonUM(val text: TextReference) {
SelectAll(text = resourceReference(R.string.address_book_select_all_networks)),
ClearAll(text = resourceReference(R.string.address_book_clear_all_networks)),
Empty(text = TextReference.EMPTY),
}
}

View file

@ -5,15 +5,19 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent
import com.tangem.features.addressbook.selectnetworks.state.SelectNetworksStateController
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ -24,6 +28,8 @@ internal class SelectNetworksModelTest {
private var model: SelectNetworksModel? = null
private val analyticsSender: AddressBookAnalyticsSender = mockk(relaxed = true)
@AfterEach
fun tearDown() {
model?.onDestroy()
@ -147,6 +153,7 @@ internal class SelectNetworksModelTest {
paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
stateController = SelectNetworksStateController(),
analyticsSender = analyticsSender,
).also { model = it }
}

View file

@ -0,0 +1,251 @@
package com.tangem.features.addressbook.selectnetworks.state.transformers
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds2.search.TangemSearch
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM.SelectAllButtonUM
import com.tangem.test.core.ProvideTestModels
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class UpdateNetworksContentTransformerTest {
private val ethereum = Blockchain.Ethereum
private val bitcoin = Blockchain.Bitcoin
private val polygon = Blockchain.Polygon
private val allBlockchains = listOf(ethereum, bitcoin, polygon)
@Test
fun `GIVEN blank query WHEN transform THEN all matched networks shown in order`() {
// Arrange
val transformer = createTransformer(matchedBlockchains = allBlockchains, query = "")
// Act
val result = transformer.transform(createPrevState())
// Assert
assertThat(result.networks.map { it.id })
.containsExactly(ethereum.toNetworkId(), bitcoin.toNetworkId(), polygon.toNetworkId())
.inOrder()
}
@Test
fun `GIVEN network item WHEN transform THEN mapped fields match blockchain`() {
// Arrange
val transformer = createTransformer(matchedBlockchains = listOf(ethereum), query = "")
// Act
val item = transformer.transform(createPrevState()).networks.single()
// Assert
assertThat(item.id).isEqualTo(ethereum.toNetworkId())
assertThat(item.name).isEqualTo(ethereum.fullName)
assertThat(item.symbol).isEqualTo(ethereum.currency)
}
@Test
fun `GIVEN non-blank query WHEN transform THEN only matching networks shown`() {
// Arrange
val transformer = createTransformer(matchedBlockchains = allBlockchains, query = "bitcoin")
// Act
val result = transformer.transform(createPrevState())
// Assert
assertThat(result.networks.map { it.id }).containsExactly(bitcoin.toNetworkId())
}
@Test
fun `GIVEN query matching nothing WHEN transform THEN no networks shown`() {
// Arrange
val transformer = createTransformer(matchedBlockchains = allBlockchains, query = "no-such-network")
// Act
val result = transformer.transform(createPrevState())
// Assert
assertThat(result.networks).isEmpty()
}
@Test
fun `GIVEN selected network ids WHEN transform THEN isSelected reflects selection`() {
// Arrange
val transformer = createTransformer(
matchedBlockchains = allBlockchains,
selectedNetworkIds = setOf(ethereum.toNetworkId(), polygon.toNetworkId()),
)
// Act
val selectionById = transformer.transform(createPrevState()).networks.associate { it.id to it.isSelected }
// Assert
assertThat(selectionById).containsExactly(
ethereum.toNetworkId(), true,
bitcoin.toNetworkId(), false,
polygon.toNetworkId(), true,
)
}
@Test
fun `GIVEN item WHEN onCheckedChange invoked THEN onToggle called with network id`() {
// Arrange
val toggled = mutableListOf<String>()
val transformer = createTransformer(
matchedBlockchains = listOf(ethereum),
onToggle = toggled::add,
)
val item = transformer.transform(createPrevState()).networks.single()
// Act
item.onCheckedChange()
// Assert
assertThat(toggled).containsExactly(ethereum.toNetworkId())
}
@Test
fun `GIVEN non-empty selection WHEN transform THEN done button enabled`() {
// Arrange
val transformer = createTransformer(
matchedBlockchains = allBlockchains,
selectedNetworkIds = setOf(ethereum.toNetworkId()),
)
// Act
val result = transformer.transform(createPrevState(isDoneEnabled = false))
// Assert
assertThat(result.doneButton.isEnabled).isTrue()
}
@Test
fun `GIVEN empty selection WHEN transform THEN done button disabled`() {
// Arrange
val transformer = createTransformer(
matchedBlockchains = allBlockchains,
selectedNetworkIds = emptySet(),
)
// Act
val result = transformer.transform(createPrevState(isDoneEnabled = true))
// Assert
assertThat(result.doneButton.isEnabled).isFalse()
}
@ParameterizedTest
@ProvideTestModels
fun `WHEN transform THEN select-all button state matches selection and search`(model: SelectAllButtonModel) {
// Arrange
val transformer = createTransformer(
matchedBlockchains = model.matchedBlockchains,
selectedNetworkIds = model.selectedNetworkIds,
isSearchActive = model.isSearchActive,
)
// Act
val result = transformer.transform(createPrevState())
// Assert
assertThat(result.selectAllButton).isEqualTo(model.expected)
}
private fun provideTestModels() = listOf(
SelectAllButtonModel(
description = "search active hides the button even when all selected",
matchedBlockchains = allBlockchains,
selectedNetworkIds = allBlockchains.map { it.toNetworkId() }.toSet(),
isSearchActive = true,
expected = SelectAllButtonUM.Empty,
),
SelectAllButtonModel(
description = "search active hides the button when none selected",
matchedBlockchains = allBlockchains,
selectedNetworkIds = emptySet(),
isSearchActive = true,
expected = SelectAllButtonUM.Empty,
),
SelectAllButtonModel(
description = "all selected shows Clear All",
matchedBlockchains = allBlockchains,
selectedNetworkIds = allBlockchains.map { it.toNetworkId() }.toSet(),
isSearchActive = false,
expected = SelectAllButtonUM.ClearAll,
),
SelectAllButtonModel(
description = "partial selection shows Select All",
matchedBlockchains = allBlockchains,
selectedNetworkIds = setOf(ethereum.toNetworkId()),
isSearchActive = false,
expected = SelectAllButtonUM.SelectAll,
),
SelectAllButtonModel(
description = "no selection shows Select All",
matchedBlockchains = allBlockchains,
selectedNetworkIds = emptySet(),
isSearchActive = false,
expected = SelectAllButtonUM.SelectAll,
),
SelectAllButtonModel(
description = "no matched networks shows Select All",
matchedBlockchains = emptyList(),
selectedNetworkIds = emptySet(),
isSearchActive = false,
expected = SelectAllButtonUM.SelectAll,
),
)
private fun createTransformer(
matchedBlockchains: List<Blockchain>,
query: String = "",
selectedNetworkIds: Set<String> = emptySet(),
isSearchActive: Boolean = false,
onToggle: (String) -> Unit = {},
) = UpdateNetworksContentTransformer(
matchedBlockchains = matchedBlockchains,
query = query,
selectedNetworkIds = selectedNetworkIds,
isSearchActive = isSearchActive,
onToggle = onToggle,
)
private fun createPrevState(isDoneEnabled: Boolean = false) = SelectNetworksUM(
searchBar = TangemSearch.State(
placeholderText = resourceReference(R.string.common_search),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = {},
),
networks = persistentListOf(),
selectAllButton = SelectAllButtonUM.Empty,
doneButton = TangemButtonUM(
text = TextReference.Res(R.string.common_done),
type = TangemButtonType.Primary,
isEnabled = isDoneEnabled,
onClick = {},
),
onBackClick = {},
onSelectAllClick = {},
)
internal data class SelectAllButtonModel(
val description: String,
val matchedBlockchains: List<Blockchain>,
val selectedNetworkIds: Set<String>,
val isSearchActive: Boolean,
val expected: SelectAllButtonUM,
) {
override fun toString(): String = description
}
}