Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-09 11:34:46 +01:00
commit e864746d2b
16 changed files with 414 additions and 4 deletions

View file

@ -11,6 +11,7 @@ import com.tangem.feature.walletsettings.component.WalletSettingsComponent
import com.tangem.features.account.AccountCreateEditComponent
import com.tangem.features.account.AccountDetailsComponent
import com.tangem.features.account.ArchivedAccountListComponent
import com.tangem.features.addressbook.AddressBookComponent
import com.tangem.features.createwalletselection.CreateWalletSelectionComponent
import com.tangem.features.createwalletstart.CreateWalletStartComponent
import com.tangem.features.details.component.DetailsComponent
@ -115,6 +116,7 @@ internal class ChildFactory @Inject constructor(
private val surveyComponentFactory: SurveyComponent.Factory,
private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory,
private val feedEntryComponentFactory: FeedEntryComponent.Factory,
private val addressBookComponentFactory: AddressBookComponent.Factory,
) {
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -744,6 +746,13 @@ internal class ChildFactory @Inject constructor(
componentFactory = feedEntryComponentFactory,
)
}
is AppRoute.AddressBook -> {
createComponentChild(
context = context,
params = AddressBookComponent.Params(route.predefinedAddress),
componentFactory = addressBookComponentFactory,
)
}
}
}
}

View file

@ -172,6 +172,11 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class WalletConnectSessions(val userWalletId: UserWalletId) : AppRoute(path = "/wallet_connect_sessions")
@Serializable
data class AddressBook(
val predefinedAddress: String? = null,
) : AppRoute(path = "/address_book/predefinedAddress/$predefinedAddress")
@Serializable
data class QrScanning(val source: Source) : AppRoute(path = "/$source/qr_scanning${source.path}") {

View file

@ -0,0 +1,11 @@
package com.tangem.features.addressbook
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface AddressBookComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, AddressBookComponent>
data class Params(val predefinedAddress: String?)
}

View file

@ -1,6 +1,7 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.serialization)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
@ -14,15 +15,30 @@ dependencies {
/** Api */
implementation(projects.features.addressBook.api)
/** Domain */
implementation(projects.domain.models)
implementation(projects.domain.addressBook)
/** Core modules */
implementation(projects.core.configToggles)
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.utils)
/** Compose */
implementation(deps.compose.runtime)
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.material3)
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.compose)
implementation(deps.decompose.ext.compose)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Other */
implementation(deps.kotlin.immutable.collections)
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.addressbook.component
import kotlinx.serialization.Serializable
@Serializable
internal sealed class AddressBookRoute {
@Serializable
data object List : AddressBookRoute()
}

View file

@ -0,0 +1,70 @@
package com.tangem.features.addressbook.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.addressbook.AddressBookComponent
import com.tangem.features.addressbook.list.AddressBookListComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddressBookComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: AddressBookComponent.Params,
private val addressBookListComponentFactory: AddressBookListComponent.Factory,
) : AddressBookComponent, AppComponentContext by context {
private val navigation = StackNavigation<AddressBookRoute>()
private val contentStack = childStack(
key = "address_book_stack",
source = navigation,
serializer = AddressBookRoute.serializer(),
initialConfiguration = AddressBookRoute.List,
handleBackButton = false,
childFactory = ::screenChild,
)
@Suppress("ReusedModifierInstance")
@Composable
override fun Content(modifier: Modifier) {
val childStack by contentStack.subscribeAsState()
Children(stack = childStack, animation = stackAnimation()) { child ->
child.instance.Content(modifier = modifier)
}
}
private fun screenChild(config: AddressBookRoute, componentContext: ComponentContext): ComposableContentComponent =
when (config) {
AddressBookRoute.List -> addressBookListComponentFactory.create(
context = childByContext(componentContext),
params = AddressBookListComponent.Params(
onContactClick = { contactId ->
// TODO [REDACTED_TASK_KEY] router.push(EditContact(contactId))
},
onAddContactClick = {
// TODO [REDACTED_TASK_KEY] router.push(AddContact)
},
),
)
}
@AssistedFactory
interface Factory : AddressBookComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddressBookComponent.Params,
): DefaultAddressBookComponent
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.addressbook.di
import com.tangem.features.addressbook.AddressBookComponent
import com.tangem.features.addressbook.component.DefaultAddressBookComponent
import com.tangem.features.addressbook.list.AddressBookListComponent
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface AddressBookComponentModule {
@Binds
@Singleton
fun bindAddressBookComponentFactory(factory: DefaultAddressBookComponent.Factory): AddressBookComponent.Factory
@Binds
@Singleton
fun bindAddressBookListComponentFactory(
factory: DefaultAddressBookListComponent.Factory,
): AddressBookListComponent.Factory
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.addressbook.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.addressbook.list.model.AddressBookListModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface AddressBookModelModule {
@Binds
@IntoMap
@ClassKey(AddressBookListModel::class)
fun bindAddressBookModel(model: AddressBookListModel): Model
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.addressbook.list
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
internal interface AddressBookListComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, AddressBookListComponent>
data class Params(
val onContactClick: (String) -> Unit,
val onAddContactClick: () -> Unit,
)
}

View file

@ -0,0 +1,43 @@
package com.tangem.features.addressbook.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.addressbook.list.contract.AddressBookListUM
import com.tangem.features.addressbook.list.model.AddressBookListModel
import com.tangem.features.addressbook.list.ui.AddressBookEmptyScreen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddressBookListComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted val params: AddressBookListComponent.Params,
) : AddressBookListComponent, AppComponentContext by context {
private val model: AddressBookListModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
when (state) {
AddressBookListUM.Empty -> AddressBookEmptyScreen(
onAddContactClick = params.onAddContactClick,
onBackClick = router::pop,
modifier = modifier,
)
is AddressBookListUM.AddressList -> TODO("[REDACTED_TASK_KEY]")
}
}
@AssistedFactory
interface Factory : AddressBookListComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddressBookListComponent.Params,
): DefaultAddressBookListComponent
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.addressbook.list.contract
import androidx.compose.runtime.Immutable
import com.tangem.domain.addressbook.model.Contact
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class AddressBookListUM {
data object Empty : AddressBookListUM()
data class AddressList(val contacts: ImmutableList<Contact>) : AddressBookListUM()
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.addressbook.list.model
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.features.addressbook.list.contract.AddressBookListUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@ModelScoped
internal class AddressBookListModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
val state: StateFlow<AddressBookListUM> = MutableStateFlow(
AddressBookListUM.Empty,
)
}

View file

@ -0,0 +1,108 @@
package com.tangem.features.addressbook.list.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.PrimaryButtonIconEnd
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.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun AddressBookEmptyScreen(
onAddContactClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = resourceReference(R.string.address_book_title),
startContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
onClick = onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Secondary,
)
},
)
NoContactInfo()
PrimaryButtonIconEnd(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
text = stringResourceSafe(R.string.address_book_add_contact),
iconResId = R.drawable.ic_plus_24,
onClick = onAddContactClick,
)
}
}
@Composable
private fun ColumnScope.NoContactInfo() {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
ContactImage()
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing24),
text = stringResourceSafe(R.string.address_book_no_contacts),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.heading.medium,
)
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
text = stringResourceSafe(R.string.address_book_no_contacts_description),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun ContactImage() {
Box(
modifier = Modifier
.size(80.dp)
.background(
color = TangemTheme.colors3.bg.status.infoSubtle,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(R.drawable.ic_contact_20),
contentDescription = stringResourceSafe(R.string.address_book_no_contacts),
modifier = Modifier.size(28.dp),
)
}
}
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_AddressBookEmptyScreen() {
AddressBookEmptyScreen(onAddContactClick = {}, onBackClick = {})
}

View file

@ -163,7 +163,7 @@ private fun Block(
BlockCard {
WalletConnectAddressBookBlockItems(
items = model.items,
modifier = itemModifier.padding(12.dp),
modifier = itemModifier,
)
}
}
@ -184,14 +184,14 @@ private fun WalletConnectAddressBookBlockItems(
items.fastForEach { item ->
when (item) {
is DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect -> InputRowImageBase(
modifier = modifier.clickable(onClick = item.onClick),
modifier = modifier.clickable(onClick = item.onClick).padding(12.dp),
iconResVector = R.drawable.ic_wallet_connect_24,
iconTint = TangemTheme.colors.icon.primary1,
subtitle = TextReference.Res(R.string.wallet_connect_title),
caption = TextReference.Res(R.string.wallet_connect_subtitle),
)
is DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook -> InputRowImageBase(
modifier = modifier.clickable(onClick = item.onClick),
modifier = modifier.clickable(onClick = item.onClick).padding(12.dp),
iconResVector = R.drawable.ic_contact_20,
iconTint = TangemTheme.colors.icon.accent,
subtitle = TextReference.Res(R.string.address_book_title),

View file

@ -114,7 +114,7 @@ internal class ItemsBuilder @Inject constructor(
private fun buildAddressBookButton(): DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook {
return DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook(
onClick = { },
onClick = { router.push(AppRoute.AddressBook()) },
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.details.utils
import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.components.block.model.BlockUM
import com.tangem.core.ui.extensions.resourceReference
@ -11,6 +12,7 @@ import com.tangem.features.details.impl.R
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -90,6 +92,51 @@ internal class ItemsBuilderTest {
)
}
@Test
fun `GIVEN standalone walletConnect block WHEN item clicked THEN router pushes WalletConnectSessions`() {
// Arrange
val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = false)
val walletConnect = result.first() as DetailsItemUM.WalletConnect
// Act
walletConnect.onClick()
// Assert
verify(exactly = 1) { router.push(route = AppRoute.WalletConnectSessions(USER_WALLET_ID), onComplete = any()) }
}
@Test
fun `GIVEN combined block walletConnect item WHEN clicked THEN router pushes WalletConnectSessions`() {
// Arrange
val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = true)
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
val walletConnect = block.items
.filterIsInstance<DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect>()
.single()
// Act
walletConnect.onClick()
// Assert
verify(exactly = 1) { router.push(route = AppRoute.WalletConnectSessions(USER_WALLET_ID), onComplete = any()) }
}
@Test
fun `GIVEN combined block addressBook item WHEN clicked THEN router pushes AddressBook`() {
// Arrange
val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = true)
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
val addressBook = block.items
.filterIsInstance<DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook>()
.single()
// Act
addressBook.onClick()
// Assert
verify(exactly = 1) { router.push(route = AppRoute.AddressBook(), onComplete = any()) }
}
@Test
fun `GIVEN walletConnect AND addressBook unavailable WHEN buildAll THEN no walletConnect block`() {
// Act