Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-10 12:00:59 +01:00
commit bfe877736c
16 changed files with 539 additions and 17 deletions

View file

@ -19,6 +19,9 @@ dependencies {
implementation(projects.domain.models)
implementation(projects.domain.addressBook)
/** Common */
implementation(projects.common.ui)
/** Core modules */
implementation(projects.core.configToggles)
implementation(projects.core.decompose)
@ -41,4 +44,7 @@ dependencies {
/** Other */
implementation(deps.kotlin.immutable.collections)
/** Tests */
testImplementation(projects.test.core)
}

View file

@ -7,4 +7,12 @@ internal sealed class AddressBookRoute {
@Serializable
data object List : AddressBookRoute()
/**
* if [contactId] is not null we should fetch existing contact
*/
@Serializable
data class EditContact(
val contactId: String? = null,
) : AddressBookRoute()
}

View file

@ -9,11 +9,15 @@ 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.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.pushNew
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.AddressBookComponent
import com.tangem.features.addressbook.list.AddressBookListComponent
import com.tangem.features.addressbook.editcontact.EditContactComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -22,6 +26,7 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: AddressBookComponent.Params,
private val addressBookListComponentFactory: AddressBookListComponent.Factory,
private val editContactComponentFactory: EditContactComponent.Factory,
) : AddressBookComponent, AppComponentContext by context {
private val navigation = StackNavigation<AddressBookRoute>()
@ -51,11 +56,16 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
context = childByContext(componentContext),
params = AddressBookListComponent.Params(
onContactClick = { contactId ->
// TODO [REDACTED_TASK_KEY] router.push(EditContact(contactId))
},
onAddContactClick = {
// TODO [REDACTED_TASK_KEY] router.push(AddContact)
navigation.pushNew(AddressBookRoute.EditContact(contactId))
},
onAddContactClick = { navigation.pushNew(AddressBookRoute.EditContact()) },
),
)
is AddressBookRoute.EditContact -> editContactComponentFactory.create(
context = childByContext(componentContext),
params = EditContactComponent.Params(
contactId = config.contactId?.let(::ContactId),
onBackClick = { navigation.pop() },
),
)
}

View file

@ -4,6 +4,8 @@ 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 com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
import com.tangem.features.addressbook.editcontact.EditContactComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -23,4 +25,8 @@ internal interface AddressBookComponentModule {
fun bindAddressBookListComponentFactory(
factory: DefaultAddressBookListComponent.Factory,
): AddressBookListComponent.Factory
@Binds
@Singleton
fun bindEditContactComponentFactory(factory: DefaultEditContactComponent.Factory): EditContactComponent.Factory
}

View file

@ -3,6 +3,7 @@ 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 com.tangem.features.addressbook.editcontact.model.EditContactModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -17,4 +18,9 @@ internal interface AddressBookModelModule {
@IntoMap
@ClassKey(AddressBookListModel::class)
fun bindAddressBookModel(model: AddressBookListModel): Model
@Binds
@IntoMap
@ClassKey(EditContactModel::class)
fun bindEditContactModel(model: EditContactModel): Model
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.addressbook.editcontact
import androidx.activity.compose.BackHandler
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.editcontact.model.EditContactModel
import com.tangem.features.addressbook.editcontact.ui.EditContactContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultEditContactComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: EditContactComponent.Params,
) : EditContactComponent, AppComponentContext by context {
private val model: EditContactModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
EditContactContent(
state = state,
modifier = modifier,
)
BackHandler(onBack = state.onCloseClick)
}
@AssistedFactory
interface Factory : EditContactComponent.Factory {
override fun create(
context: AppComponentContext,
params: EditContactComponent.Params,
): DefaultEditContactComponent
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.addressbook.editcontact
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId
internal interface EditContactComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, EditContactComponent>
data class Params(
val contactId: ContactId?,
val onBackClick: () -> Unit,
)
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.addressbook.editcontact.contract
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class EditContactUM(
val title: TextReference,
val name: String,
val namePlaceholder: TextReference,
val portfolioIcon: AccountIconUM.CryptoPortfolio,
val colors: Colors,
val onNameChange: (String) -> Unit,
val onCloseClick: () -> Unit,
) {
@Immutable
data class Colors(
val selected: CryptoPortfolioIcon.Color,
val list: ImmutableList<CryptoPortfolioIcon.Color>,
val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit,
)
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.addressbook.editcontact.model
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.EditContactComponent
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class EditContactModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params: EditContactComponent.Params = paramsContainer.require()
val state: StateFlow<EditContactUM>
field = MutableStateFlow(getInitialState())
private fun onNameChange(name: String) {
state.update { it.copy(name = name) }
}
private fun onColorSelect(color: CryptoPortfolioIcon.Color) {
state.update { oldState ->
oldState.copy(
colors = oldState.colors.copy(selected = color),
portfolioIcon = oldState.portfolioIcon.copy(color = color),
)
}
}
private fun getInitialState(): EditContactUM {
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
val selectedColor = colors.first()
val titleResId = if (params.contactId == null) {
R.string.address_book_new_contact
} else {
R.string.address_book_contact
}
return EditContactUM(
title = resourceReference(titleResId),
name = "",
namePlaceholder = resourceReference(R.string.address_book_new_contact),
portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = selectedColor,
),
colors = EditContactUM.Colors(
selected = selectedColor,
list = colors,
onColorSelect = ::onColorSelect,
),
onNameChange = ::onNameChange,
onCloseClick = params.onBackClick,
)
}
}

View file

@ -0,0 +1,185 @@
package com.tangem.features.addressbook.editcontact.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.account.AccountIcon
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.getUiColor
import com.tangem.core.ui.R
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.fields.AutoSizeTextField
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.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
import kotlinx.collections.immutable.toImmutableList
@Composable
internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(color = TangemTheme.colors3.bg.primary)
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = state.title,
endContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24),
onClick = state.onCloseClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
ContactSummary(state = state)
ContactColor(colors = state.colors)
}
}
}
@Composable
private fun ContactSummary(state: EditContactUM) {
val avatarName = state.name.ifBlank { state.namePlaceholder.resolveReference() }
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(24.dp))
AccountIcon(
name = stringReference(avatarName),
icon = state.portfolioIcon,
size = AccountIconSize.Large,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResourceSafe(R.string.address_book_contact_name),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.tertiary,
)
Spacer(modifier = Modifier.height(2.dp))
AutoSizeTextField(
value = state.name,
onValueChange = state.onNameChange,
centered = true,
singleLine = true,
placeholder = state.namePlaceholder,
textStyle = TangemTheme.typography3.heading.medium,
color = TangemTheme.colors3.text.primary,
placeholderColor = TangemTheme.colors3.text.tertiary,
)
Spacer(modifier = Modifier.height(20.dp))
}
}
@OptIn(ExperimentalLayoutApi::class)
@Suppress("MagicNumber")
@Composable
private fun ContactColor(colors: EditContactUM.Colors) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
) {
FlowRow(
maxItemsInEachRow = 6,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
) {
colors.list.fastForEach { color ->
val isSelected = color == colors.selected
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(CircleShape)
.clickable(onClick = { colors.onColorSelect(color) })
.size(48.dp),
) {
if (isSelected) {
Box(
modifier = Modifier
.size(47.dp)
.border(2.dp, color.getUiColor(), shape = CircleShape),
)
Box(
modifier = Modifier
.size(36.dp)
.background(color = color.getUiColor(), shape = CircleShape),
)
} else {
Box(
modifier = Modifier
.size(40.dp)
.background(color = color.getUiColor(), shape = CircleShape),
)
}
}
}
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_EditContactContent() {
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
TangemThemePreview {
EditContactContent(
state = EditContactUM(
title = stringReference("New contact"),
name = "",
namePlaceholder = stringReference("New contact"),
portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = colors.first(),
),
colors = EditContactUM.Colors(
selected = colors.first(),
list = colors,
onColorSelect = {},
),
onNameChange = {},
onCloseClick = {},
),
)
}
}

View file

@ -23,9 +23,9 @@ internal class DefaultAddressBookListComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
when (state) {
AddressBookListUM.Empty -> AddressBookEmptyScreen(
onAddContactClick = params.onAddContactClick,
when (val addressBookListUM = state) {
is AddressBookListUM.Empty -> AddressBookEmptyScreen(
tangemButtonUM = addressBookListUM.tangemButtonUM,
onBackClick = router::pop,
modifier = modifier,
)

View file

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

View file

@ -2,6 +2,16 @@ 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.core.ui.R
import com.tangem.core.ui.R.drawable.ic_plus_24
import com.tangem.core.ui.ds.button.TangemButtonIconPosition
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.addressbook.list.AddressBookListComponent
import com.tangem.features.addressbook.list.contract.AddressBookListUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
@ -10,10 +20,24 @@ import javax.inject.Inject
@ModelScoped
internal class AddressBookListModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params = paramsContainer.require<AddressBookListComponent.Params>()
val state: StateFlow<AddressBookListUM> = MutableStateFlow(
AddressBookListUM.Empty,
AddressBookListUM.Empty(
tangemButtonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_new_contact),
tangemIconUM = TangemIconUM.Icon(
iconRes = ic_plus_24,
tintReference = { TangemTheme.colors3.text.inverse.primary },
),
iconPosition = TangemButtonIconPosition.End,
type = TangemButtonType.Primary,
onClick = params.onAddContactClick,
),
),
)
}

View file

@ -14,17 +14,21 @@ 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.button.PrimaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonIconPosition
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
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.TextReference
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,
tangemButtonUM: TangemButtonUM,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -40,19 +44,17 @@ internal fun AddressBookEmptyScreen(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
onClick = onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Secondary,
variant = TangemButton.Variant.Material,
)
},
)
NoContactInfo()
PrimaryButtonIconEnd(
PrimaryTangemButton(
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,
buttonUM = tangemButtonUM,
)
}
}
@ -104,5 +106,14 @@ private fun ContactImage() {
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_AddressBookEmptyScreen() {
AddressBookEmptyScreen(onAddContactClick = {}, onBackClick = {})
AddressBookEmptyScreen(
tangemButtonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_new_contact),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_plus_24),
iconPosition = TangemButtonIconPosition.End,
type = TangemButtonType.Secondary,
onClick = {},
),
onBackClick = {},
)
}

View file

@ -0,0 +1,113 @@
package com.tangem.features.addressbook.editcontact.model
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.EditContactComponent
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class EditContactModelTest {
@Test
fun `WHEN model created THEN initial state is correct`() = runTest {
val expectedColors = CryptoPortfolioIcon.Color.entries.toImmutableList()
val expectedSelectedColor = expectedColors.first()
val model = createModel(testScope = this)
val state = model.state.value
val expected = EditContactUM(
title = resourceReference(R.string.address_book_new_contact),
name = "",
namePlaceholder = resourceReference(R.string.address_book_new_contact),
portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = expectedSelectedColor,
),
colors = EditContactUM.Colors(
selected = expectedSelectedColor,
list = expectedColors,
onColorSelect = state.colors.onColorSelect,
),
onNameChange = state.onNameChange,
onCloseClick = state.onCloseClick,
)
assertThat(state).isEqualTo(expected)
}
@Test
fun `GIVEN existing contactId WHEN model created THEN title is contact`() = runTest {
// Arrange
val params = EditContactComponent.Params(
contactId = ContactId(value = "contact-id"),
onBackClick = {},
)
// Act
val model = createModel(testScope = this, params = params)
val state = model.state.value
// Assert
assertThat(state.title).isEqualTo(resourceReference(R.string.address_book_contact))
}
@Test
fun `GIVEN initial state WHEN onNameChange THEN name updated`() = runTest {
val model = createModel(testScope = this)
val newName = "Satoshi"
model.state.value.onNameChange(newName)
assertThat(model.state.value.name).isEqualTo(newName)
}
@Test
fun `GIVEN initial state WHEN onColorSelect THEN selected color and portfolio icon updated`() = runTest {
val model = createModel(testScope = this)
val newColor = CryptoPortfolioIcon.Color.entries.last()
model.state.value.colors.onColorSelect(newColor)
val state = model.state.value
assertThat(state.colors.selected).isEqualTo(newColor)
assertThat(state.portfolioIcon.color).isEqualTo(newColor)
}
private fun createModel(
testScope: TestScope,
params: EditContactComponent.Params = EditContactComponent.Params(
contactId = null,
onBackClick = {},
),
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
): EditContactModel {
return EditContactModel(
paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}