Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-01 12:49:20 +04:00
parent f75a69abfe
commit 4d7ae277a4
14 changed files with 498 additions and 10 deletions

View file

@ -1,3 +1,3 @@
package com.tangem.datasource.api.common.response
const val IF_NONE_MATCH_HEADER = "IfNoneMatch"
const val ETAG_HEADER = "etag"

View file

@ -11,7 +11,7 @@ import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
import com.tangem.datasource.api.common.response.IF_NONE_MATCH_HEADER
import com.tangem.datasource.api.common.response.ETAG_HEADER
import com.tangem.datasource.api.common.response.isNetworkError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
@ -86,7 +86,11 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
eTag = eTag,
body = body,
body = body.copy(
accounts = body.accounts.map {
it.copy(tokens = null, totalTokens = null, totalNetworks = null)
},
),
)
}
@ -140,7 +144,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
private suspend fun assignTokens(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) {
val accountsResponseWithTokens = accountsResponse.assignTokens(userWalletId)
pushAndStore(userWalletId = userWalletId, response = accountsResponseWithTokens)
store(userWalletId = userWalletId, response = accountsResponseWithTokens)
userTokensSaver.push(
userWalletId = userWalletId,
@ -153,7 +157,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
}
private suspend fun saveETag(userWalletId: UserWalletId, apiResponse: ApiResponse<*>) {
val eTag = apiResponse.headers[IF_NONE_MATCH_HEADER]?.firstOrNull()
val eTag = apiResponse.headers[ETAG_HEADER]?.firstOrNull()
if (eTag != null) {
eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = eTag)

View file

@ -17,6 +17,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
typealias AccountsResponseStore = DataStore<GetWalletAccountsResponse?>
@ -30,6 +31,7 @@ typealias AccountsResponseStore = DataStore<GetWalletAccountsResponse?>
*
[REDACTED_AUTHOR]
*/
@Singleton
internal class AccountsResponseStoreFactory @Inject constructor(
@ApplicationContext private val context: Context,
@NetworkMoshi private val moshi: Moshi,

View file

@ -8,7 +8,7 @@ import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.IF_NONE_MATCH_HEADER
import com.tangem.datasource.api.common.response.ETAG_HEADER
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
@ -85,7 +85,7 @@ class DefaultWalletAccountsFetcherTest {
val newETag = "newEtag"
val apiResponse = ApiResponse.Success(
data = accountsResponse,
headers = mapOf(IF_NONE_MATCH_HEADER to listOf(newETag)),
headers = mapOf(ETAG_HEADER to listOf(newETag)),
)
accountsResponseStoreFlow.value = savedAccountsResponse
@ -156,7 +156,7 @@ class DefaultWalletAccountsFetcherTest {
val newETag = "newEtag"
val apiResponse = ApiResponse.Success(
data = accountsResponse,
headers = mapOf(IF_NONE_MATCH_HEADER to listOf(newETag)),
headers = mapOf(ETAG_HEADER to listOf(newETag)),
)
accountsResponseStoreFlow.value = savedAccountsResponse
@ -247,7 +247,7 @@ class DefaultWalletAccountsFetcherTest {
val newETag = "newEtag"
val apiResponse = ApiResponse.Success(
data = accountsResponse,
headers = mapOf(IF_NONE_MATCH_HEADER to listOf(newETag)),
headers = mapOf(ETAG_HEADER to listOf(newETag)),
)
val apiError = ApiResponseError.HttpException(

View file

@ -34,6 +34,11 @@ internal class DefaultETagsStore(
appPreferencesStore.store(key = key, value = value)
}
override suspend fun clear(userWalletId: UserWalletId, key: ETagsStore.Key) {
val key = getAccountsETagKey(userWalletId = userWalletId, key = key)
appPreferencesStore.editData { it.remove(key) }
}
private fun getAccountsETagKey(userWalletId: UserWalletId, key: ETagsStore.Key): Preferences.Key<String> {
return stringPreferencesKey(name = "etag_${key}_${userWalletId.stringValue}")
}

View file

@ -25,6 +25,14 @@ interface ETagsStore {
*/
suspend fun store(userWalletId: UserWalletId, key: Key, value: String)
/**
* Clears the stored ETag value for the specified wallet and key
*
* @param userWalletId identifier of the user wallet
* @param key the key for which to get the ETag value
*/
suspend fun clear(userWalletId: UserWalletId, key: Key)
/** Enumeration of possible keys for storing ETag values */
enum class Key {
WalletAccounts,

View file

@ -29,6 +29,7 @@ dependencies {
kapt(deps.hilt.kapt)
/** Domain modules */
implementation(projects.domain.account)
implementation(projects.domain.appTheme)
implementation(projects.domain.appTheme.models)
implementation(projects.domain.card)
@ -40,6 +41,8 @@ dependencies {
implementation(projects.domain.wallets.models)
implementation(projects.domain.wallets)
implementation(projects.data.common)
/** Other libraries */
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
@ -62,6 +65,7 @@ dependencies {
/** Other modules */
implementation(projects.common.routing)
implementation(projects.common.ui)
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
}

View file

@ -16,6 +16,8 @@ import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeActivity
import com.tangem.feature.tester.presentation.accounts.ui.AccountsScreen
import com.tangem.feature.tester.presentation.accounts.viewmodel.AccountsViewModel
import com.tangem.feature.tester.presentation.actions.TesterActionsScreen
import com.tangem.feature.tester.presentation.actions.TesterActionsViewModel
import com.tangem.feature.tester.presentation.environments.ui.EnvironmentTogglesScreen
@ -62,7 +64,7 @@ internal class TesterActivity : ComposeActivity() {
TesterNavHost()
}
@Suppress("TopLevelComposableFunctions", "LongMethod")
@Suppress("TopLevelComposableFunctions", "LongMethod", "CyclomaticComplexMethod")
@Composable
private fun TesterNavHost() {
val navController = rememberNavController().also { innerTesterRouter.setNavController(it) }
@ -79,6 +81,7 @@ internal class TesterActivity : ComposeActivity() {
ButtonUM.BLOCKCHAIN_PROVIDERS,
ButtonUM.TESTER_ACTIONS,
ButtonUM.TEST_PUSHES,
ButtonUM.ACCOUNTS,
),
onButtonClick = {
val route = when (it) {
@ -88,6 +91,7 @@ internal class TesterActivity : ComposeActivity() {
ButtonUM.BLOCKCHAIN_PROVIDERS -> TesterScreen.BLOCKCHAIN_PROVIDERS
ButtonUM.TESTER_ACTIONS -> TesterScreen.TESTER_ACTIONS
ButtonUM.TEST_PUSHES -> TesterScreen.TEST_PUSHES
ButtonUM.ACCOUNTS -> TesterScreen.ACCOUNTS
}
innerTesterRouter.open(route)
@ -149,6 +153,15 @@ internal class TesterActivity : ComposeActivity() {
TestPushScreen(state, viewModel)
}
composable(route = TesterScreen.ACCOUNTS.name) {
val viewModel = hiltViewModel<AccountsViewModel>().apply {
setupNavigation(innerTesterRouter)
}
val state by viewModel.uiState.collectAsStateWithLifecycle()
AccountsScreen(state)
}
}
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.feature.tester.presentation.accounts.entity
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import kotlinx.collections.immutable.ImmutableList
internal data class AccountsUM(
val onBackClick: () -> Unit,
val walletSelector: WalletSelector,
val accountListBottomSheetConfig: AccountListBottomSheetConfig,
val onAccountsClick: () -> Unit,
val onFetchAccountsClick: () -> Unit,
val onCreateMainAccountClick: () -> Unit,
val onClearETagClick: () -> Unit,
) {
data class WalletSelector(
val selected: UserWallet?,
val wallets: ImmutableList<UserWallet>,
val onWalletSelect: (UserWallet) -> Unit,
) : TangemBottomSheetConfigContent
data class AccountListBottomSheetConfig(
val accounts: ImmutableList<Account.CryptoPortfolio>,
) : TangemBottomSheetConfigContent
}

View file

@ -0,0 +1,257 @@
package com.tangem.feature.tester.presentation.accounts.ui
import android.content.Context
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.divider.DividerWithPadding
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.feature.tester.impl.R
import com.tangem.feature.tester.presentation.accounts.entity.AccountsUM
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun AccountsScreen(state: AccountsUM, modifier: Modifier = Modifier) {
BackHandler(onBack = state.onBackClick)
var isWalletSelectorShown by remember { mutableStateOf(false) }
var isAccountListShown by remember { mutableStateOf(false) }
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary),
) {
stickyHeader { AppBar(onBackClick = state.onBackClick) }
item {
WalletInfoBlock(
walletName = state.walletSelector.selected?.name,
onClick = {
if (state.walletSelector.wallets.isNotEmpty()) {
isWalletSelectorShown = true
}
},
)
}
if (state.walletSelector.selected != null) {
ManageAccountsButtons(
state = state,
onAccountsClick = { context ->
if (state.accountListBottomSheetConfig.accounts.isNotEmpty()) {
state.onAccountsClick()
isAccountListShown = true
} else {
Toast.makeText(context, "No accounts found", Toast.LENGTH_SHORT).show()
}
},
)
}
}
WalletSelector(
isShown = isWalletSelectorShown,
onDismissRequest = { isWalletSelectorShown = false },
content = state.walletSelector,
)
AccountList(
isShown = isAccountListShown,
onDismissRequest = { isAccountListShown = false },
content = state.accountListBottomSheetConfig,
)
}
@Composable
private fun AppBar(onBackClick: () -> Unit) {
AppBarWithBackButton(
onBackClick = onBackClick,
text = stringResourceSafe(id = R.string.accounts),
containerColor = TangemTheme.colors.background.primary,
)
}
@Composable
private fun WalletInfoBlock(walletName: String?, onClick: () -> Unit) {
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth()
.heightIn(min = 48.dp)
.background(color = TangemTheme.colors.background.secondary, shape = RoundedCornerShape(16.dp))
.clickable(onClick = onClick)
.padding(vertical = 12.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Icon(
painter = painterResource(R.drawable.ic_wallet_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
val text by remember(walletName) { mutableStateOf(walletName ?: "Choose wallet") }
Text(
text = text,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
Icon(
painter = painterResource(R.drawable.ic_chevron_right_24),
contentDescription = null,
tint = TangemTheme.colors.icon.secondary,
)
}
}
@Composable
private fun WalletSelector(isShown: Boolean, onDismissRequest: () -> Unit, content: AccountsUM.WalletSelector) {
TangemBottomSheet(
config = TangemBottomSheetConfig(
isShown = isShown,
onDismissRequest = onDismissRequest,
content = content,
),
titleText = stringReference("Choose wallet"),
) { content: AccountsUM.WalletSelector ->
content.wallets.fastForEachIndexed { index, wallet ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
content.onWalletSelect(wallet)
onDismissRequest()
}
.padding(horizontal = 24.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = wallet.name,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
if (wallet.walletId == content.selected?.walletId) {
Icon(
painter = painterResource(R.drawable.ic_check_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
}
if (index == content.wallets.lastIndex) {
Spacer(modifier = Modifier.height(16.dp))
} else {
DividerWithPadding(start = 16.dp, end = 16.dp)
}
}
}
}
@Composable
private fun AccountList(
isShown: Boolean,
onDismissRequest: () -> Unit,
content: AccountsUM.AccountListBottomSheetConfig,
) {
TangemBottomSheet(
config = TangemBottomSheetConfig(
isShown = isShown,
onDismissRequest = onDismissRequest,
content = content,
),
titleText = stringReference("Accounts"),
) { content: AccountsUM.AccountListBottomSheetConfig ->
content.accounts.fastForEachIndexed { index, account ->
val accountName = account.accountName.toUM().value.resolveReference()
Text(
text = "#${account.derivationIndex.value} $accountName",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
if (index == content.accounts.lastIndex) {
Spacer(modifier = Modifier.height(16.dp))
} else {
DividerWithPadding(start = 16.dp, end = 16.dp)
}
}
}
}
@Suppress("FunctionNaming")
private fun LazyListScope.ManageAccountsButtons(state: AccountsUM, onAccountsClick: (Context) -> Unit) {
item {
val context = LocalContext.current
PrimaryButton(
text = stringResourceSafe(R.string.accounts),
onClick = { onAccountsClick(context) },
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
)
}
item {
PrimaryButton(
text = "Fetch accounts",
onClick = state.onFetchAccountsClick,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
)
}
item {
PrimaryButton(
text = "Clear ETag",
onClick = state.onClearETagClick,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
)
}
if (state.accountListBottomSheetConfig.accounts.none { it.isMainAccount }) {
item {
PrimaryButton(
text = "Create Main account",
onClick = state.onCreateMainAccountClick,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
)
}
}
}

View file

@ -0,0 +1,165 @@
package com.tangem.feature.tester.presentation.accounts.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.domain.account.fetcher.SingleAccountListFetcher
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.tester.presentation.accounts.entity.AccountsUM
import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
internal class AccountsViewModel @Inject constructor(
private val userWalletsListRepository: UserWalletsListRepository,
private val singleAccountListFetcher: SingleAccountListFetcher,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val accountsCRUDRepository: AccountsCRUDRepository,
private val eTagsStore: ETagsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : ViewModel() {
val uiState: StateFlow<AccountsUM>
field = MutableStateFlow(getInitialState())
private val walletAccounts: StateFlow<Map<UserWalletId, AccountList>>
field = MutableStateFlow(emptyMap())
private var router: InnerTesterRouter? = null
init {
viewModelScope.launch {
val userWallets = userWalletsListRepository.userWalletsSync()
uiState.update { state ->
state.copy(
walletSelector = state.walletSelector.copy(wallets = userWallets.toImmutableList()),
)
}
userWallets.mapIndexed { index, userWallet ->
singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId = userWallet.walletId))
.distinctUntilChanged()
.onEach { accountList ->
walletAccounts.update { currentMap ->
currentMap.toMutableMap().apply {
this[userWallet.walletId] = accountList
}
}
}
.flowOn(dispatchers.default)
.launchIn(viewModelScope)
}
}
}
fun setupNavigation(router: InnerTesterRouter) {
this.router = router
}
private fun getInitialState(): AccountsUM {
return AccountsUM(
onBackClick = { router?.back() },
walletSelector = AccountsUM.WalletSelector(
selected = null,
wallets = persistentListOf(),
onWalletSelect = ::onWalletSelect,
),
accountListBottomSheetConfig = AccountsUM.AccountListBottomSheetConfig(
accounts = persistentListOf(),
),
onAccountsClick = ::updateAccountsList,
onFetchAccountsClick = ::fetchAccounts,
onCreateMainAccountClick = ::createMainAccount,
onClearETagClick = ::clearETag,
)
}
private fun onWalletSelect(userWallet: UserWallet) {
uiState.update {
it.copy(
walletSelector = it.walletSelector.copy(selected = userWallet),
accountListBottomSheetConfig = it.accountListBottomSheetConfig.copy(
accounts = walletAccounts.value[userWallet.walletId]?.accounts
?.filterIsInstance<Account.CryptoPortfolio>()
?.toImmutableList()
?: persistentListOf(),
),
)
}
}
private fun updateAccountsList() {
val userWalletId = uiState.value.walletSelector.selected?.walletId ?: return
val accounts = walletAccounts.value[userWalletId]?.accounts
?.filterIsInstance<Account.CryptoPortfolio>()
?.toImmutableList()
?: persistentListOf()
uiState.update { state ->
state.copy(
accountListBottomSheetConfig = state.accountListBottomSheetConfig.copy(
accounts = accounts,
),
)
}
}
private fun fetchAccounts() {
viewModelScope.launch {
val userWalletId = uiState.value.walletSelector.selected?.walletId ?: return@launch
singleAccountListFetcher(
params = SingleAccountListFetcher.Params(userWalletId = userWalletId),
)
}
}
private fun createMainAccount() {
viewModelScope.launch {
val userWallet = uiState.value.walletSelector.selected ?: return@launch
// It's temporary solution to create main account for testing purposes
val accountList = AccountList(
userWallet = userWallet,
accounts = setOf(
Account.CryptoPortfolio.createMainAccount(userWallet.walletId).copy(
accountName = AccountName.invoke(value = "Main Account").getOrNull()!!,
),
),
totalAccounts = 1,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
)
.getOrNull()!!
accountsCRUDRepository.saveAccounts(accountList)
}
}
private fun clearETag() {
viewModelScope.launch {
val userWallet = uiState.value.walletSelector.selected ?: return@launch
eTagsStore.clear(userWalletId = userWallet.walletId, key = ETagsStore.Key.WalletAccounts)
}
}
}

View file

@ -24,5 +24,6 @@ data class TesterMenuUM(
BLOCKCHAIN_PROVIDERS(R.string.blockchain_providers),
TESTER_ACTIONS(R.string.tester_actions),
TEST_PUSHES(R.string.test_push),
ACCOUNTS(R.string.accounts),
}
}

View file

@ -13,4 +13,5 @@ internal enum class TesterScreen {
EXCLUDED_BLOCKCHAINS,
BLOCKCHAIN_PROVIDERS,
TEST_PUSHES,
ACCOUNTS,
}

View file

@ -17,4 +17,5 @@
<string name="blockchain_providers" translatable="false">Blockchain providers</string>
<string name="share_logs" translatable="false">Share logs</string>
<string name="test_push" translatable="false">Test push</string>
<string name="accounts" translatable="false">Accounts</string>
</resources>