Updated on 2026-08-14
This commit is contained in:
parent
80ae462385
commit
4807321b9a
15 changed files with 397 additions and 1 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}") {
|
||||
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
}
|
||||
|
|
@ -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,13 +15,24 @@ dependencies {
|
|||
/** Api */
|
||||
implementation(projects.features.addressBook.api)
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.models)
|
||||
|
||||
/** 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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package com.tangem.features.addressbook.component
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal sealed class AddressBookRoute {
|
||||
|
||||
@Serializable
|
||||
data object List : AddressBookRoute()
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package com.tangem.features.addressbook.component
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
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()
|
||||
|
||||
BackHandler(onBack = router::pop)
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddressBookComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddressBookComponent.Params,
|
||||
): DefaultAddressBookComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
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 object Params
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
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.AddressBookListEvent
|
||||
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 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 = { model.onAction(event = AddressBookListEvent.NewContactClick) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddressBookListComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddressBookListComponent.Params,
|
||||
): DefaultAddressBookListComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.features.addressbook.list.contract
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
internal sealed interface AddressBookListUM {
|
||||
|
||||
data object Empty : AddressBookListUM
|
||||
}
|
||||
|
||||
internal sealed interface AddressBookListEvent {
|
||||
data object NewContactClick : AddressBookListEvent
|
||||
data class ContactClick(val contactId: String) : AddressBookListEvent
|
||||
data class ChipClick(val walletId: String) : AddressBookListEvent
|
||||
data class SearchInput(val query: String) : AddressBookListEvent
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.tangem.features.addressbook.list.model
|
||||
|
||||
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.list.AddressBookListComponent
|
||||
import com.tangem.features.addressbook.list.contract.AddressBookListEvent
|
||||
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(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
) : Model() {
|
||||
|
||||
private val params: AddressBookListComponent.Params = paramsContainer.require()
|
||||
|
||||
val state: StateFlow<AddressBookListUM> = MutableStateFlow(
|
||||
AddressBookListUM.Empty,
|
||||
)
|
||||
|
||||
fun onAction(event: AddressBookListEvent) {
|
||||
when (event) {
|
||||
AddressBookListEvent.NewContactClick -> params
|
||||
is AddressBookListEvent.ContactClick -> Unit
|
||||
is AddressBookListEvent.ChipClick -> Unit
|
||||
is AddressBookListEvent.SearchInput -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
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.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
||||
@Composable
|
||||
internal fun AddressBookEmptyScreen(onAddContactClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
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 = {})
|
||||
}
|
||||
|
|
@ -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()) },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue