Updated on 2026-08-14
This commit is contained in:
parent
bc24aeb9f3
commit
c77fcafdc2
11 changed files with 380 additions and 1 deletions
4
.idea/inspectionProfiles/Project_Default.xml
generated
4
.idea/inspectionProfiles/Project_Default.xml
generated
|
|
@ -75,6 +75,10 @@
|
|||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import com.tangem.domain.visa.model.VisaTxDetails
|
|||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class EmailMessageBodyResolver(
|
||||
class EmailMessageBodyResolver(
|
||||
private val feedbackRepository: FeedbackRepository,
|
||||
) {
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ plugins {
|
|||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
alias(deps.plugins.kotlin.serialization)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +41,7 @@ dependencies {
|
|||
implementation(projects.domain.manageTokens)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.feedback.models)
|
||||
|
||||
implementation(projects.data.common)
|
||||
|
||||
|
|
@ -68,4 +70,5 @@ dependencies {
|
|||
implementation(projects.common.ui)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
implementation(deps.kotlin.serialization)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.tangem.feature.tester.presentation
|
|||
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -20,6 +21,8 @@ import com.tangem.feature.tester.presentation.accounts.ui.AccountsScreen
|
|||
import com.tangem.feature.tester.presentation.accounts.viewmodel.TesterAccountsViewModel
|
||||
import com.tangem.feature.tester.presentation.actions.TesterActionsScreen
|
||||
import com.tangem.feature.tester.presentation.actions.TesterActionsViewModel
|
||||
import com.tangem.feature.tester.presentation.addresses.ui.AddressesInfoScreen
|
||||
import com.tangem.feature.tester.presentation.addresses.viewmodels.AddressesInfoViewModel
|
||||
import com.tangem.feature.tester.presentation.environments.ui.EnvironmentTogglesScreen
|
||||
import com.tangem.feature.tester.presentation.environments.viewmodels.EnvironmentsTogglesViewModel
|
||||
import com.tangem.feature.tester.presentation.excludedblockchains.ExcludedBlockchainsScreen
|
||||
|
|
@ -82,6 +85,7 @@ internal class TesterActivity : ComposeActivity() {
|
|||
ButtonUM.TESTER_ACTIONS,
|
||||
ButtonUM.TEST_PUSHES,
|
||||
ButtonUM.ACCOUNTS,
|
||||
ButtonUM.ADDRESSES_INFO,
|
||||
),
|
||||
onButtonClick = { buttonUM ->
|
||||
val route = when (buttonUM) {
|
||||
|
|
@ -92,6 +96,7 @@ internal class TesterActivity : ComposeActivity() {
|
|||
ButtonUM.TESTER_ACTIONS -> TesterScreen.TESTER_ACTIONS
|
||||
ButtonUM.TEST_PUSHES -> TesterScreen.TEST_PUSHES
|
||||
ButtonUM.ACCOUNTS -> TesterScreen.ACCOUNTS
|
||||
ButtonUM.ADDRESSES_INFO -> TesterScreen.ADDRESSES_INFO
|
||||
}
|
||||
|
||||
innerTesterRouter.open(route)
|
||||
|
|
@ -162,6 +167,18 @@ internal class TesterActivity : ComposeActivity() {
|
|||
|
||||
AccountsScreen(state)
|
||||
}
|
||||
|
||||
composable(route = TesterScreen.ADDRESSES_INFO.name) {
|
||||
val viewModel = hiltViewModel<AddressesInfoViewModel>()
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.setupNavigation(innerTesterRouter)
|
||||
}
|
||||
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
AddressesInfoScreen(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.tangem.feature.tester.presentation.addresses.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TesterAddressJson(
|
||||
val addresses: List<String>,
|
||||
val blockchain: String,
|
||||
val derivationPath: String,
|
||||
val token: String? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.tangem.feature.tester.presentation.addresses.state
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
/**
|
||||
* Content state of addresses info screen
|
||||
*
|
||||
* @property title title
|
||||
* @property addressesText addresses in text format
|
||||
* @property addressesJson addresses in JSON format
|
||||
* @property onBackClick the lambda to be invoked when back button is pressed
|
||||
*/
|
||||
internal data class AddressesInfoScreenUM(
|
||||
@StringRes val title: Int,
|
||||
val addressesText: String,
|
||||
val addressesJson: String,
|
||||
val onBackClick: () -> Unit,
|
||||
val onCopyClick: (CopyType) -> Unit,
|
||||
)
|
||||
|
||||
internal enum class CopyType {
|
||||
TEXT,
|
||||
JSON,
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package com.tangem.feature.tester.presentation.addresses.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.feature.tester.presentation.addresses.state.AddressesInfoScreenUM
|
||||
import com.tangem.feature.tester.presentation.addresses.state.CopyType
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
|
||||
/**
|
||||
* Screen with addresses info in text and JSON format
|
||||
*
|
||||
* @param uiModel screen state
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun AddressesInfoScreen(uiModel: AddressesInfoScreenUM) {
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
val tabs = listOf("Text", "JSON")
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppBarWithBackButton(
|
||||
onBackClick = uiModel.onBackClick,
|
||||
text = stringResourceSafe(id = uiModel.title),
|
||||
modifier = Modifier.statusBarsPadding(),
|
||||
)
|
||||
},
|
||||
containerColor = TangemTheme.colors.background.secondary,
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
containerColor = TangemTheme.colors.background.secondary,
|
||||
contentColor = TangemTheme.colors.text.primary1,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.Indicator(
|
||||
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTab]),
|
||||
color = TangemTheme.colors.text.accent,
|
||||
)
|
||||
},
|
||||
) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = {
|
||||
Text(
|
||||
text = title,
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
color = if (selectedTab == index) {
|
||||
TangemTheme.colors.text.primary1
|
||||
} else {
|
||||
TangemTheme.colors.text.secondary
|
||||
},
|
||||
)
|
||||
},
|
||||
selectedContentColor = TangemTheme.colors.text.primary1,
|
||||
unselectedContentColor = TangemTheme.colors.text.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
uiModel.onCopyClick(
|
||||
if (selectedTab == 0) CopyType.TEXT else CopyType.JSON,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_copy_24),
|
||||
contentDescription = "Copy",
|
||||
tint = TangemTheme.colors.icon.primary1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tab content
|
||||
when (selectedTab) {
|
||||
0 -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = uiModel.addressesText,
|
||||
style = TangemTheme.typography.body1,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1 -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = uiModel.addressesJson,
|
||||
style = TangemTheme.typography.body1,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
package com.tangem.feature.tester.presentation.addresses.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tangem.core.ui.clipboard.ClipboardManager
|
||||
import com.tangem.domain.feedback.models.BlockchainInfo
|
||||
import com.tangem.domain.feedback.models.FeedbackEmailType
|
||||
import com.tangem.domain.feedback.models.WalletMetaInfo
|
||||
import com.tangem.domain.feedback.repository.FeedbackRepository
|
||||
import com.tangem.domain.feedback.utils.EmailMessageBodyResolver
|
||||
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
|
||||
import com.tangem.feature.tester.impl.R
|
||||
import com.tangem.feature.tester.presentation.addresses.model.TesterAddressJson
|
||||
import com.tangem.feature.tester.presentation.addresses.state.AddressesInfoScreenUM
|
||||
import com.tangem.feature.tester.presentation.addresses.state.CopyType
|
||||
import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Tester screen for displaying addresses and tokens.
|
||||
* Uses the same data source as feedback emails to ensure identical output.
|
||||
*/
|
||||
|
||||
@HiltViewModel
|
||||
internal class AddressesInfoViewModel @Inject constructor(
|
||||
private val feedbackRepository: FeedbackRepository,
|
||||
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
|
||||
private val clipboardManager: ClipboardManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val emailMessageBodyResolver = EmailMessageBodyResolver(feedbackRepository)
|
||||
|
||||
private val _uiState = MutableStateFlow(getInitialState())
|
||||
val uiState: StateFlow<AddressesInfoScreenUM> = _uiState.asStateFlow()
|
||||
|
||||
fun setupNavigation(router: InnerTesterRouter) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
onBackClick = router::back,
|
||||
onCopyClick = ::onCopyClicked,
|
||||
)
|
||||
}
|
||||
|
||||
loadAddresses()
|
||||
}
|
||||
|
||||
private fun getInitialState(): AddressesInfoScreenUM = AddressesInfoScreenUM(
|
||||
title = R.string.addresses_info,
|
||||
addressesText = "Loading...",
|
||||
addressesJson = "[]",
|
||||
onBackClick = {},
|
||||
onCopyClick = {},
|
||||
)
|
||||
|
||||
private fun loadAddresses() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val wallet = getSelectedWalletSyncUseCase()
|
||||
.getOrNull() ?: run {
|
||||
setEmptyState()
|
||||
return@launch
|
||||
}
|
||||
|
||||
val walletMetaInfo = WalletMetaInfo(
|
||||
userWalletId = wallet.walletId,
|
||||
)
|
||||
|
||||
val emailType = FeedbackEmailType.DirectUserRequest(
|
||||
walletMetaInfo = walletMetaInfo,
|
||||
)
|
||||
|
||||
val emailBody = emailMessageBodyResolver.resolve(emailType)
|
||||
|
||||
val blockchainInfos = feedbackRepository.getBlockchainInfoList(wallet.walletId)
|
||||
|
||||
val json = formatAddressesJson(blockchainInfos)
|
||||
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
addressesText = emailBody,
|
||||
addressesJson = json,
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
addressesText = "Error loading addresses",
|
||||
addressesJson = errorJson(e),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setEmptyState() {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
addressesText = "No active addresses found",
|
||||
addressesJson = "[]",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("JSON_FORMAT_REDUNDANT")
|
||||
private fun formatAddressesJson(list: List<BlockchainInfo>): String {
|
||||
val jsonList = list
|
||||
.flatMap { info ->
|
||||
val addresses = info
|
||||
.extractAddresses()
|
||||
.sortedDescending()
|
||||
|
||||
val tokens = if (info.tokens.isNotEmpty()) {
|
||||
info.tokens.map { it.name }
|
||||
} else {
|
||||
listOf(info.blockchain)
|
||||
}
|
||||
|
||||
tokens.map { token ->
|
||||
TesterAddressJson(
|
||||
addresses = addresses,
|
||||
blockchain = info.blockchain,
|
||||
derivationPath = info.derivationPath,
|
||||
token = token,
|
||||
)
|
||||
}
|
||||
}
|
||||
.sortedWith(
|
||||
compareBy<TesterAddressJson> { it.blockchain }
|
||||
.thenBy { it.derivationPath }
|
||||
.thenBy { it.token },
|
||||
)
|
||||
|
||||
return Json { prettyPrint = true }
|
||||
.encodeToString(jsonList)
|
||||
}
|
||||
|
||||
private fun BlockchainInfo.extractAddresses(): List<String> = when (val a = addresses) {
|
||||
is BlockchainInfo.Addresses.Single ->
|
||||
listOf(a.value)
|
||||
|
||||
is BlockchainInfo.Addresses.Multiple ->
|
||||
a.values.map { it.value }
|
||||
}
|
||||
|
||||
private fun errorJson(error: Throwable): String = Json.encodeToString(
|
||||
mapOf("error" to (error.message ?: "Unknown error")),
|
||||
)
|
||||
|
||||
private fun onCopyClicked(type: CopyType) {
|
||||
val state = _uiState.value
|
||||
|
||||
val text = when (type) {
|
||||
CopyType.TEXT -> state.addressesText
|
||||
CopyType.JSON -> state.addressesJson
|
||||
}
|
||||
|
||||
if (text.isNotBlank()) {
|
||||
clipboardManager.setText(
|
||||
text = text,
|
||||
isSensitive = false,
|
||||
label = type.name,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,5 +25,6 @@ data class TesterMenuUM(
|
|||
TESTER_ACTIONS(R.string.tester_actions),
|
||||
TEST_PUSHES(R.string.test_push),
|
||||
ACCOUNTS(R.string.accounts),
|
||||
ADDRESSES_INFO(R.string.addresses_info),
|
||||
}
|
||||
}
|
||||
|
|
@ -14,4 +14,5 @@ internal enum class TesterScreen {
|
|||
BLOCKCHAIN_PROVIDERS,
|
||||
TEST_PUSHES,
|
||||
ACCOUNTS,
|
||||
ADDRESSES_INFO,
|
||||
}
|
||||
|
|
@ -21,4 +21,5 @@
|
|||
<string name="news" translatable="false">News</string>
|
||||
<string name="news_details" translatable="false">News details</string>
|
||||
<string name="news_details_bottom_sheet" translatable="false">News details (Bottom Sheet)</string>
|
||||
<string name="addresses_info" translatable="false">Addresses info</string>
|
||||
</resources>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue