Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-11 18:55:37 +03:00
parent 330ae73357
commit e7efe7b76a
4636 changed files with 234864 additions and 63507 deletions

1
features/tester/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,59 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.feature.tester.impl"
}
dependencies {
/** AndroidX */
implementation(deps.androidx.activity.compose)
/** Compose */
implementation(deps.compose.accompanist.systemUiController)
implementation(deps.compose.foundation)
implementation(deps.compose.material)
implementation(deps.compose.material3)
implementation(deps.compose.navigation)
implementation(deps.compose.navigation.hilt)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.ui.utils)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Domain modules */
implementation(projects.domain.appTheme)
implementation(projects.domain.appTheme.models)
implementation(projects.domain.feedback)
/** Other libraries */
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
implementation(deps.timber)
/** Core modules */
implementation(projects.core.datasource)
implementation(projects.core.configToggles)
implementation(projects.core.ui)
implementation(projects.core.utils)
implementation(projects.core.navigation)
/** Feature Apis */
implementation(projects.features.tester.api)
/* SDK */
implementation(tangemDeps.blockchain)
/** Other modules */
implementation(projects.common.routing)
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="com.tangem.feature.tester.presentation.TesterActivity"
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar"
android:exported="false" />
</application>
</manifest>

View file

@ -0,0 +1,18 @@
package com.tangem.feature.tester.di
import com.tangem.feature.tester.presentation.navigation.DefaultTesterRouter
import com.tangem.features.tester.api.TesterRouter
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.scopes.ActivityScoped
@Module
@InstallIn(ActivityComponent::class)
internal interface TesterRouterModule {
@Binds
@ActivityScoped
fun bindTesterRouter(defaultTesterRouter: DefaultTesterRouter): TesterRouter
}

View file

@ -0,0 +1,146 @@
package com.tangem.feature.tester.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.tangem.common.routing.AppRouter
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.actions.TesterActionsScreen
import com.tangem.feature.tester.presentation.actions.TesterActionsViewModel
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
import com.tangem.feature.tester.presentation.excludedblockchains.ExcludedBlockchainsViewModel
import com.tangem.feature.tester.presentation.featuretoggles.ui.FeatureTogglesScreen
import com.tangem.feature.tester.presentation.featuretoggles.viewmodels.FeatureTogglesViewModel
import com.tangem.feature.tester.presentation.menu.state.TesterMenuUM
import com.tangem.feature.tester.presentation.menu.state.TesterMenuUM.ButtonUM
import com.tangem.feature.tester.presentation.menu.ui.TesterMenuScreen
import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter
import com.tangem.feature.tester.presentation.navigation.TesterScreen
import com.tangem.feature.tester.presentation.providers.ui.BlockchainProvidersScreen
import com.tangem.feature.tester.presentation.providers.viewmodel.BlockchainProvidersViewModel
import com.tangem.features.tester.api.TesterRouter
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.collections.immutable.persistentSetOf
import javax.inject.Inject
/** Activity for testers */
@AndroidEntryPoint
internal class TesterActivity : ComposeActivity() {
@Inject
override lateinit var uiDependencies: UiDependencies
/** Router for inner feature navigation */
@Inject
lateinit var testerRouter: TesterRouter
@Inject
lateinit var appFinisher: AppFinisher
@Inject
lateinit var appRouter: AppRouter
private val innerTesterRouter: InnerTesterRouter
get() = requireNotNull(testerRouter as? InnerTesterRouter) {
"TesterRouter must be InnerTesterRouter for tester feature"
}
@Composable
override fun ScreenContent(modifier: Modifier) {
val systemBarsColor = TangemTheme.colors.background.secondary
val systemUiController = rememberSystemUiController()
systemUiController.setSystemBarsColor(systemBarsColor)
TesterNavHost()
}
@Suppress("TopLevelComposableFunctions")
@Composable
private fun TesterNavHost() {
val navController = rememberNavController().also { innerTesterRouter.setNavController(it) }
NavHost(navController = navController, startDestination = TesterScreen.MENU.name) {
composable(route = TesterScreen.MENU.name) {
TesterMenuScreen(
state = TesterMenuUM(
onBackClick = { appRouter.pop { finish() } },
buttons = persistentSetOf(
ButtonUM.FEATURE_TOGGLES,
ButtonUM.EXCLUDED_BLOCKCHAINS,
ButtonUM.ENVIRONMENT_TOGGLES,
ButtonUM.BLOCKCHAIN_PROVIDERS,
ButtonUM.TESTER_ACTIONS,
),
onButtonClick = {
val route = when (it) {
ButtonUM.FEATURE_TOGGLES -> TesterScreen.FEATURE_TOGGLES
ButtonUM.EXCLUDED_BLOCKCHAINS -> TesterScreen.EXCLUDED_BLOCKCHAINS
ButtonUM.ENVIRONMENT_TOGGLES -> TesterScreen.ENVIRONMENTS_TOGGLES
ButtonUM.BLOCKCHAIN_PROVIDERS -> TesterScreen.BLOCKCHAIN_PROVIDERS
ButtonUM.TESTER_ACTIONS -> TesterScreen.TESTER_ACTIONS
}
innerTesterRouter.open(route)
},
),
)
}
composable(route = TesterScreen.FEATURE_TOGGLES.name) {
val viewModel = hiltViewModel<FeatureTogglesViewModel>().apply {
setupInteractions(innerTesterRouter, appFinisher)
}
FeatureTogglesScreen(state = viewModel.uiState)
}
composable(route = TesterScreen.ENVIRONMENTS_TOGGLES.name) {
val viewModel = hiltViewModel<EnvironmentsTogglesViewModel>().apply {
setupNavigation(innerTesterRouter)
}
EnvironmentTogglesScreen(
uiModel = viewModel.uiState.collectAsState().value,
)
}
composable(route = TesterScreen.TESTER_ACTIONS.name) {
val viewModel = hiltViewModel<TesterActionsViewModel>().apply {
setupNavigation(innerTesterRouter)
}
TesterActionsScreen(state = viewModel.uiState)
}
composable(route = TesterScreen.EXCLUDED_BLOCKCHAINS.name) {
val viewModel = hiltViewModel<ExcludedBlockchainsViewModel>().apply {
setupNavigation(innerTesterRouter, appFinisher)
}
val state by viewModel.state.collectAsStateWithLifecycle()
ExcludedBlockchainsScreen(state = state)
}
composable(route = TesterScreen.BLOCKCHAIN_PROVIDERS.name) {
val viewModel = hiltViewModel<BlockchainProvidersViewModel>().apply {
setupNavigation(innerTesterRouter, appFinisher)
}
val state by viewModel.state.collectAsStateWithLifecycle()
BlockchainProvidersScreen(state)
}
}
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.feature.tester.presentation.actions
import androidx.compose.runtime.Immutable
import com.tangem.domain.apptheme.model.AppThemeMode
import java.io.File
internal data class TesterActionsContentState(
val hideAllCurrenciesUM: HideAllCurrenciesUM,
val toggleAppThemeUM: ToggleAppThemeUM,
val shareLogsUM: ShareLogsUM,
val onBackClick: () -> Unit,
) {
sealed class HideAllCurrenciesUM {
data class Clickable(val onClick: () -> Unit) : HideAllCurrenciesUM()
data object Progress : HideAllCurrenciesUM()
}
data class ToggleAppThemeUM(
val currentAppTheme: AppThemeMode,
val onClick: () -> Unit,
)
@Immutable
data class ShareLogsUM(val file: File?)
}

View file

@ -0,0 +1,144 @@
package com.tangem.feature.tester.presentation.actions
import android.app.Activity
import android.content.Intent
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.app.ShareCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.tangem.core.ui.components.PrimaryButton
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.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.findActivity
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.feature.tester.impl.R
import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.HideAllCurrenciesUM
import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.ToggleAppThemeUM
import timber.log.Timber
import java.io.File
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun TesterActionsScreen(state: TesterActionsContentState, modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.secondary),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
stickyHeader {
AppBarWithBackButton(
text = stringResourceSafe(R.string.tester_actions),
onBackClick = state.onBackClick,
)
}
item {
val onClick = remember(state.hideAllCurrenciesUM) {
{ (state.hideAllCurrenciesUM as? HideAllCurrenciesUM.Clickable)?.onClick?.invoke() ?: Unit }
}
TesterActionItem(
name = stringResourceSafe(R.string.hide_all_currencies),
progress = state.hideAllCurrenciesUM is HideAllCurrenciesUM.Progress,
onClick = onClick,
)
}
item {
val config = state.toggleAppThemeUM
TesterActionItem(
name = stringResourceSafe(id = R.string.toggle_app_theme, config.currentAppTheme.name),
onClick = config.onClick,
)
}
item {
val activity = LocalContext.current.findActivity()
TesterActionItem(
name = stringResourceSafe(id = R.string.share_logs),
onClick = { activity.shareFile(file = state.shareLogsUM.file) },
enabled = state.shareLogsUM.file != null,
)
}
}
}
@Composable
private fun TesterActionItem(name: String, onClick: () -> Unit, progress: Boolean = false, enabled: Boolean = true) {
PrimaryButton(
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
text = name,
onClick = onClick,
showProgress = progress,
enabled = enabled,
)
}
private fun Activity.shareFile(file: File?) {
val originalIntent = createEmailShareIntent(activity = this, file = file)
try {
val chooserIntent = Intent.createChooser(originalIntent, "Share logs...")
ContextCompat.startActivity(this, chooserIntent, null)
} catch (ex: Exception) {
Timber.e("Failed to share file: $ex")
}
}
private fun createEmailShareIntent(activity: Activity, file: File?): Intent {
val builder = ShareCompat.IntentBuilder(activity)
.setType("text/plain")
file?.let {
builder.setStream(
FileProvider.getUriForFile(activity, "${activity.packageName}.provider", it),
)
}
return builder.intent
}
// region Preview
@Composable
private fun TesterActionsScreenSample(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(TangemTheme.colors.background.primary),
) {
TesterActionsScreen(
state = TesterActionsContentState(
hideAllCurrenciesUM = HideAllCurrenciesUM.Clickable {},
toggleAppThemeUM = ToggleAppThemeUM(AppThemeMode.DEFAULT) {},
shareLogsUM = TesterActionsContentState.ShareLogsUM(file = null),
onBackClick = {},
),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TesterActionsScreenPreview() {
TangemThemePreview {
TesterActionsScreenSample()
}
}
// endregion Preview

View file

@ -0,0 +1,122 @@
package com.tangem.feature.tester.presentation.actions
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import arrow.core.getOrElse
import com.tangem.domain.apptheme.ChangeAppThemeModeUseCase
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.feedback.repository.FeedbackRepository
import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.HideAllCurrenciesUM
import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.ToggleAppThemeUM
import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter
import com.tangem.lib.crypto.UserWalletManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
internal class TesterActionsViewModel @Inject constructor(
private val userWalletManager: UserWalletManager,
private val changeAppThemeModeUseCase: ChangeAppThemeModeUseCase,
private val getAppThemeModeUseCase: GetAppThemeModeUseCase,
private val feedbackRepository: FeedbackRepository,
) : ViewModel() {
var uiState: TesterActionsContentState by mutableStateOf(initialState)
private set
private val initialState: TesterActionsContentState
get() = TesterActionsContentState(
hideAllCurrenciesUM = HideAllCurrenciesUM.Clickable(this::hideAllCurrencies),
toggleAppThemeUM = ToggleAppThemeUM(
currentAppTheme = AppThemeMode.DEFAULT,
onClick = this::toggleAppTheme,
),
shareLogsUM = TesterActionsContentState.ShareLogsUM(file = feedbackRepository.getLogFile()),
onBackClick = { /* no-op */ },
)
init {
bootstrapAppThemeModeUpdates()
}
fun setupNavigation(router: InnerTesterRouter) {
uiState = uiState.copy(onBackClick = router::back)
}
private fun hideAllCurrencies() = viewModelScope.launch {
uiState = uiState.copy(
hideAllCurrenciesUM = HideAllCurrenciesUM.Progress,
)
userWalletManager.hideAllTokens()
uiState = uiState.copy(
hideAllCurrenciesUM = HideAllCurrenciesUM.Clickable(this@TesterActionsViewModel::hideAllCurrencies),
)
}
private fun toggleAppTheme() = viewModelScope.launch {
val currentAppThemeMode = uiState.toggleAppThemeUM.currentAppTheme
val newAppThemeMode = when (currentAppThemeMode) {
AppThemeMode.FORCE_DARK -> AppThemeMode.FORCE_LIGHT
AppThemeMode.FORCE_LIGHT -> AppThemeMode.FOLLOW_SYSTEM
AppThemeMode.FOLLOW_SYSTEM -> AppThemeMode.FORCE_DARK
}
Timber.d(
"""
Change app theme mode
|- Current theme mode: $currentAppThemeMode
|- New theme mode: $newAppThemeMode
""".trimIndent(),
)
changeAppThemeModeUseCase(newAppThemeMode).onLeft { error ->
Timber.e(
"""
Unable to change app theme mode
|- Error: $error
""".trimIndent(),
)
}
}
private fun bootstrapAppThemeModeUpdates() {
getAppThemeModeUseCase()
.distinctUntilChanged()
.onEach { maybeAppThemeMode ->
Timber.d(
"""
Current app theme mode updated
|- Previous app theme mode: ${uiState.toggleAppThemeUM.currentAppTheme}
|- New app theme mode: $maybeAppThemeMode
""".trimIndent(),
)
uiState = uiState.copy(
toggleAppThemeUM = uiState.toggleAppThemeUM.copy(
currentAppTheme = maybeAppThemeMode.getOrElse { error ->
Timber.e(
"""
Unable to get current app theme mode, using default
|- Default theme mode: ${AppThemeMode.DEFAULT}
|- Error: $error
""".trimIndent(),
)
AppThemeMode.DEFAULT
},
),
)
}
.launchIn(viewModelScope)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.feature.tester.presentation.common.components.appbar
import androidx.compose.runtime.Composable
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tester.impl.R
@Composable
internal fun TopBarWithRefresh(state: TopBarWithRefreshUM) {
AppBarWithBackButtonAndIcon(
onBackClick = state.onBackClick,
text = stringResourceSafe(state.titleResId),
iconRes = R.drawable.ic_refresh_24.takeIf { state.refreshButton.isVisible },
onIconClick = state.refreshButton.onRefreshClick,
backgroundColor = TangemTheme.colors.background.primary,
)
}

View file

@ -0,0 +1,12 @@
package com.tangem.feature.tester.presentation.common.components.appbar
import androidx.annotation.StringRes
internal data class TopBarWithRefreshUM(
@StringRes val titleResId: Int,
val onBackClick: () -> Unit,
val refreshButton: RefreshButton,
) {
data class RefreshButton(val isVisible: Boolean, val onRefreshClick: () -> Unit)
}

View file

@ -0,0 +1,21 @@
package com.tangem.feature.tester.presentation.common.components.notification
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.feature.tester.impl.R
@Composable
internal fun CustomSetupNotification(subtitle: TextReference, modifier: Modifier = Modifier) {
Notification(
config = NotificationConfig(
subtitle = subtitle,
iconResId = R.drawable.ic_alert_triangle_20,
title = resourceReference(id = R.string.custom_setup_warning_title),
),
modifier = modifier,
)
}

View file

@ -0,0 +1,35 @@
package com.tangem.feature.tester.presentation.environments.state
import androidx.annotation.StringRes
import kotlinx.collections.immutable.ImmutableSet
/**
* Content state of environment toggles screen
*
* @property title title
* @property apiInfoList environment toggles list
* @property onEnvironmentSelect the lambda to be invoked when button is pressed
* @property onBackClick the lambda to be invoked when back button is pressed
*/
internal data class EnvironmentTogglesScreenUM(
@StringRes val title: Int,
val apiInfoList: ImmutableSet<ApiInfoUM>,
val onEnvironmentSelect: (id: String, environment: String) -> Unit,
val onBackClick: () -> Unit,
) {
/**
* Api info
*
* @property name api name
* @property select select environment
* @property url select url
* @property environments list of environments
*/
data class ApiInfoUM(
val name: String,
val select: String,
val url: String,
val environments: ImmutableSet<String>,
)
}

View file

@ -0,0 +1,171 @@
package com.tangem.feature.tester.presentation.environments.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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.core.ui.res.TangemThemePreview
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.feature.tester.impl.R
import com.tangem.feature.tester.presentation.environments.state.EnvironmentTogglesScreenUM
import kotlinx.collections.immutable.persistentSetOf
/**
* Screen with environment toggles list
*
* @param uiModel screen state
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun EnvironmentTogglesScreen(uiModel: EnvironmentTogglesScreenUM) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(TangemTheme.colors.background.secondary),
) {
stickyHeader {
AppBarWithBackButton(
onBackClick = uiModel.onBackClick,
text = stringResourceSafe(id = uiModel.title),
)
}
itemsIndexed(
items = uiModel.apiInfoList.toTypedArray(),
key = { _, info -> info.name },
) { index, info ->
EnvironmentButtons(
uiModel = info,
onSelect = { isChange -> uiModel.onEnvironmentSelect(info.name, isChange) },
modifier = Modifier.padding(
bottom = if (index == uiModel.apiInfoList.toTypedArray().lastIndex) {
TangemTheme.dimens.spacing0
} else {
TangemTheme.dimens.spacing10
},
),
)
}
}
}
@Composable
private fun EnvironmentButtons(
uiModel: EnvironmentTogglesScreenUM.ApiInfoUM,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6),
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = uiModel.name,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
style = TangemTheme.typography.h3,
)
AnimatedContent(targetState = uiModel.url, label = "") {
Text(
text = it,
color = TangemTheme.colors.text.accent,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.body2,
)
}
}
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
uiModel.environments.onEachIndexed { index, environment ->
key(environment) {
SegmentedButton(
selected = environment == uiModel.select,
onClick = { onSelect(environment) },
shape = when (index) {
0 -> RoundedCornerShape(
topStart = TangemTheme.dimens.radius12,
bottomStart = TangemTheme.dimens.radius12,
)
uiModel.environments.toTypedArray().lastIndex -> RoundedCornerShape(
topEnd = TangemTheme.dimens.radius12,
bottomEnd = TangemTheme.dimens.radius12,
)
else -> RectangleShape
},
colors = SegmentedButtonDefaults.colors(
activeContainerColor = TangemTheme.colors.control.checked,
activeContentColor = TangemTheme.colors.text.primary2,
inactiveContainerColor = TangemTheme.colors.control.unchecked,
inactiveContentColor = TangemTheme.colors.text.primary1,
),
border = BorderStroke(0.dp, TangemTheme.colors.background.tertiary),
) {
Text(text = environment, style = TangemTheme.typography.subtitle2)
}
}
}
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewFeatureTogglesScreen() {
TangemThemePreview {
var select by remember { mutableStateOf(ApiEnvironment.DEV.name) }
EnvironmentTogglesScreen(
uiModel = EnvironmentTogglesScreenUM(
title = R.string.environment_toggles,
apiInfoList = persistentSetOf(
EnvironmentTogglesScreenUM.ApiInfoUM(
name = ApiConfig.ID.Express.name,
select = select,
url = "https://api.express.tangem.com",
environments = persistentSetOf(
ApiEnvironment.DEV.name,
ApiEnvironment.STAGE.name,
ApiEnvironment.PROD.name,
),
),
EnvironmentTogglesScreenUM.ApiInfoUM(
name = ApiConfig.ID.TangemTech.name,
select = select,
url = "https://api.express.tangem.com",
environments = persistentSetOf(
ApiEnvironment.DEV.name,
ApiEnvironment.PROD.name,
),
),
),
onEnvironmentSelect = { _: String, s1: String -> select = s1 },
onBackClick = {},
),
)
}
}

View file

@ -0,0 +1,98 @@
package com.tangem.feature.tester.presentation.environments.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.config.managers.MutableApiConfigsManager
import com.tangem.feature.tester.impl.BuildConfig
import com.tangem.feature.tester.impl.R
import com.tangem.feature.tester.presentation.environments.state.EnvironmentTogglesScreenUM
import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* ViewModel for screen with list of environment toggles
*
* @param apiConfigsManager manager for getting information about the api configs
*
[REDACTED_AUTHOR]
*/
@HiltViewModel
internal class EnvironmentsTogglesViewModel @Inject constructor(
apiConfigsManager: ApiConfigsManager,
) : ViewModel() {
/** Current ui state */
val uiState: StateFlow<EnvironmentTogglesScreenUM>
get() = _uiState
private val _uiState = MutableStateFlow(value = getInitialState())
private val mutableApiConfigsManager: MutableApiConfigsManager =
requireNotNull(apiConfigsManager as? MutableApiConfigsManager) {
"MutableApiConfigsManager isn't available in build type ${BuildConfig.BUILD_TYPE}."
}
init {
subscribeOnApiConfigs()
}
/** Setup navigation state property by router [router] */
fun setupNavigation(router: InnerTesterRouter) {
_uiState.update {
it.copy(onBackClick = router::back)
}
}
private fun subscribeOnApiConfigs() {
mutableApiConfigsManager.configs
.onEach { configs ->
_uiState.update {
it.copy(apiInfoList = configs.toUiModel())
}
}
.launchIn(viewModelScope)
}
private fun Map<ApiConfig, ApiEnvironment>.toUiModel(): ImmutableSet<EnvironmentTogglesScreenUM.ApiInfoUM> {
return mapNotNull {
val (config, currentEnvironment) = it
if (config.environmentConfigs.size <= 1) return@mapNotNull null
EnvironmentTogglesScreenUM.ApiInfoUM(
name = config.id.name,
select = currentEnvironment.name,
url = config.environmentConfigs.firstOrNull { it.environment == currentEnvironment }?.baseUrl
?: error("Current environment's url isn't found"),
environments = config.environmentConfigs
.map { environmentConfig -> environmentConfig.environment.name }
.toImmutableSet(),
)
}
.toImmutableSet()
}
private fun getInitialState(): EnvironmentTogglesScreenUM {
return EnvironmentTogglesScreenUM(
title = R.string.environment_toggles,
apiInfoList = persistentSetOf(),
onEnvironmentSelect = ::onToggleValueChange,
onBackClick = {},
)
}
private fun onToggleValueChange(id: String, name: String) {
viewModelScope.launch {
mutableApiConfigsManager.changeEnvironment(id = id, environment = ApiEnvironment.valueOf(name))
}
}
}

View file

@ -0,0 +1,190 @@
package com.tangem.feature.tester.presentation.excludedblockchains
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.TangemSwitch
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomFade
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.components.rows.RowContentContainer
import com.tangem.core.ui.components.rows.RowText
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.tester.impl.R
import com.tangem.feature.tester.presentation.excludedblockchains.state.BlockchainUM
import com.tangem.feature.tester.presentation.excludedblockchains.state.ExcludedBlockchainsScreenUM
import com.tangem.feature.tester.presentation.excludedblockchains.state.mapper.toUiModels
import kotlinx.collections.immutable.toPersistentList
@Composable
internal fun ExcludedBlockchainsScreen(state: ExcludedBlockchainsScreenUM, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier,
containerColor = TangemTheme.colors.background.primary,
topBar = {
TangemTopAppBar(
title = resourceReference(R.string.excluded_blockchains),
startButton = TopAppBarButtonUM.Back(onBackClicked = state.popBack),
endButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_refresh_24,
onIconClicked = state.onRecoverClick,
),
)
},
floatingActionButtonPosition = FabPosition.Center,
floatingActionButton = {
PrimaryButton(
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
text = "Restart App",
onClick = state.onRestartClick,
)
},
content = { paddingValues ->
LazyColumn(
modifier = Modifier
.bottomFade()
.padding(paddingValues)
.fillMaxSize(),
contentPadding = PaddingValues(
top = 8.dp,
bottom = 96.dp,
start = 16.dp,
end = 16.dp,
),
) {
item {
SearchBar(
modifier = Modifier.padding(bottom = 12.dp),
state = state.search,
)
}
item {
TogglesNotification(
modifier = Modifier.padding(vertical = 12.dp),
appVersion = state.appVersion,
isVisible = state.showRecoverWarning,
)
}
items(
items = state.blockchains,
key = BlockchainUM::id,
) { item ->
BlockchainItem(
model = item,
)
}
}
},
)
}
@Composable
private fun TogglesNotification(appVersion: String, isVisible: Boolean, modifier: Modifier = Modifier) {
if (isVisible) {
Notification(
modifier = modifier,
config = NotificationConfig(
subtitle = resourceReference(
id = R.string.feature_toggles_custom_setup_warning_description,
wrappedList(appVersion),
),
iconResId = R.drawable.ic_alert_triangle_20,
title = resourceReference(id = R.string.custom_setup_warning_title),
),
)
}
}
@Composable
private fun BlockchainItem(model: BlockchainUM, modifier: Modifier = Modifier) {
RowContentContainer(
modifier = modifier
.fillMaxWidth()
.heightIn(min = TangemTheme.dimens.size52),
icon = {
Image(
modifier = Modifier
.align(Alignment.Center)
.size(TangemTheme.dimens.size22),
painter = painterResource(id = model.iconResId),
contentDescription = null,
)
},
text = {
RowText(
mainText = model.name,
secondText = model.symbol,
accentMainText = false,
accentSecondText = false,
)
},
action = {
TangemSwitch(
checked = model.isExcluded,
onCheckedChange = model.onExcludedStateChange,
)
},
)
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_ExcludedBlockchainsScreen(
@PreviewParameter(ExcludedBlockchainsScreenPreviewProvider::class) params: ExcludedBlockchainsScreenUM,
) {
TangemThemePreview {
ExcludedBlockchainsScreen(params)
}
}
private class ExcludedBlockchainsScreenPreviewProvider : PreviewParameterProvider<ExcludedBlockchainsScreenUM> {
override val values: Sequence<ExcludedBlockchainsScreenUM>
get() = sequenceOf(
ExcludedBlockchainsScreenUM(
blockchains = Blockchain.entries.toUiModels(
excludedBlockchainsIds = setOf(Blockchain.Ethereum.id, Blockchain.Solana.id),
onExcludedStateChange = { _, _ -> },
).toPersistentList(),
showRecoverWarning = true,
appVersion = "1.0.0",
search = SearchBarUM(
placeholderText = resourceReference(R.string.excluded_blockchains_search_placeholder),
query = "",
isActive = false,
onQueryChange = {},
onActiveChange = {},
),
popBack = {},
onRecoverClick = {},
onRestartClick = {},
),
)
}
// endregion Preview

View file

@ -0,0 +1,150 @@
package com.tangem.feature.tester.presentation.excludedblockchains
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager
import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.feature.tester.impl.R
import com.tangem.feature.tester.presentation.excludedblockchains.state.BlockchainUM
import com.tangem.feature.tester.presentation.excludedblockchains.state.ExcludedBlockchainsScreenUM
import com.tangem.feature.tester.presentation.excludedblockchains.state.mapper.toUiModels
import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter
import com.tangem.utils.version.AppVersionProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class ExcludedBlockchainsViewModel @Inject constructor(
private val appVersionProvider: AppVersionProvider,
excludedBlockchainsManager: MutableExcludedBlockchainsManager?,
) : ViewModel() {
private val excludedBlockchainsManager: MutableExcludedBlockchainsManager =
requireNotNull(excludedBlockchainsManager) {
"Mutable excluded blockchains manager can't be null when tester actions is available"
}
val state: MutableStateFlow<ExcludedBlockchainsScreenUM> = MutableStateFlow(
value = getInitialState(),
)
init {
observeQueryUpdates()
}
fun setupNavigation(router: InnerTesterRouter, appFinisher: AppFinisher) {
state.update { state ->
state.copy(
popBack = router::back,
onRestartClick = appFinisher::restart,
)
}
}
private fun getInitialState(): ExcludedBlockchainsScreenUM = ExcludedBlockchainsScreenUM(
popBack = {},
search = getInitialSearchBar(),
blockchains = getBlockchains(),
showRecoverWarning = !excludedBlockchainsManager.isMatchLocalConfig(),
appVersion = appVersionProvider.versionName,
onRestartClick = {},
onRecoverClick = ::recoverLocalConfig,
)
@OptIn(FlowPreview::class)
private fun observeQueryUpdates() {
state
.map { it.search.query }
.distinctUntilChanged()
.drop(count = 1) // Skip initial value
.sample(periodMillis = 1_000)
.onEach(::filterBlockchains)
.launchIn(viewModelScope)
}
private fun recoverLocalConfig() = viewModelScope.launch {
excludedBlockchainsManager.recoverLocalConfig()
state.update { state ->
state.copy(
search = getInitialSearchBar(),
blockchains = getBlockchains(),
)
}
}
private fun getBlockchains(): PersistentList<BlockchainUM> {
val excludedBlockchains = excludedBlockchainsManager.excludedBlockchainsIds
return Blockchain.entries.toUiModels(
excludedBlockchainsIds = excludedBlockchains,
onExcludedStateChange = ::excludeBlockchain,
).toPersistentList()
}
private fun getInitialSearchBar(): SearchBarUM = SearchBarUM(
placeholderText = resourceReference(R.string.excluded_blockchains_search_placeholder),
query = "",
isActive = false,
onQueryChange = ::updateQuery,
onActiveChange = ::updateSearchBarState,
)
private fun excludeBlockchain(blockchain: Blockchain, isExcluded: Boolean) = viewModelScope.launch {
excludedBlockchainsManager.excludeBlockchain(blockchain.id, isExcluded)
state.update { state ->
state.copy(
blockchains = state.blockchains.mutate { mutableBlockchains ->
val index = mutableBlockchains.indexOfFirst { it.id == blockchain.id }
if (index == -1) return@mutate
mutableBlockchains[index] = mutableBlockchains[index].copy(isExcluded = isExcluded)
},
showRecoverWarning = !excludedBlockchainsManager.isMatchLocalConfig(),
)
}
}
private fun filterBlockchains(query: String) {
val filteredBlockchains = if (query.isBlank()) {
getBlockchains()
} else {
getBlockchains().filter { blockchain ->
blockchain.name.contains(query, ignoreCase = true) ||
blockchain.symbol.contains(query, ignoreCase = true)
}.toPersistentList()
}
state.update { state ->
state.copy(
blockchains = filteredBlockchains,
)
}
}
private fun updateQuery(query: String) {
state.update { state ->
state.copy(
search = state.search.copy(query = query),
)
}
}
private fun updateSearchBarState(isActive: Boolean) {
state.update { state ->
state.copy(
search = state.search.copy(isActive = isActive),
)
}
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.feature.tester.presentation.excludedblockchains.state
internal data class BlockchainUM(
val id: String,
val name: String,
val symbol: String,
val iconResId: Int,
val isExcluded: Boolean,
val onExcludedStateChange: (isExcluded: Boolean) -> Unit,
)

View file

@ -0,0 +1,14 @@
package com.tangem.feature.tester.presentation.excludedblockchains.state
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import kotlinx.collections.immutable.PersistentList
internal data class ExcludedBlockchainsScreenUM(
val popBack: () -> Unit,
val search: SearchBarUM,
val blockchains: PersistentList<BlockchainUM>,
val showRecoverWarning: Boolean,
val appVersion: String,
val onRecoverClick: () -> Unit,
val onRestartClick: () -> Unit,
)

View file

@ -0,0 +1,31 @@
package com.tangem.feature.tester.presentation.excludedblockchains.state.mapper
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.feature.tester.presentation.excludedblockchains.state.BlockchainUM
internal fun List<Blockchain>.toUiModels(
excludedBlockchainsIds: Set<String>,
onExcludedStateChange: (Blockchain, Boolean) -> Unit,
): List<BlockchainUM> = mapNotNull { blockchain ->
blockchain
.takeUnless { it == Blockchain.Unknown || it.isTestnet() }
?.toUiModel(
isExcluded = blockchain.id in excludedBlockchainsIds,
onExcludedStateChange = { isExcluded ->
onExcludedStateChange(blockchain, isExcluded)
},
)
}
.sortedBy(BlockchainUM::name)
.sortedByDescending(BlockchainUM::isExcluded)
private fun Blockchain.toUiModel(isExcluded: Boolean, onExcludedStateChange: (Boolean) -> Unit): BlockchainUM =
BlockchainUM(
id = id,
name = name,
symbol = currency,
iconResId = getActiveIconRes(id),
isExcluded = isExcluded,
onExcludedStateChange = onExcludedStateChange,
)

View file

@ -0,0 +1,14 @@
package com.tangem.feature.tester.presentation.featuretoggles.models
/**
* Presentation model of feature toggle
*
* @property name name
* @property isEnabled availability
*
[REDACTED_AUTHOR]
*/
internal data class TesterFeatureToggle(
val name: String,
val isEnabled: Boolean,
)

View file

@ -0,0 +1,22 @@
package com.tangem.feature.tester.presentation.featuretoggles.state
import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWithRefreshUM
import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatureToggle
import kotlinx.collections.immutable.ImmutableList
/**
* Content state of feature toggles screen
*
* @property topBar top bar state
* @property appVersion app version
* @property featureToggles feature toggles list
* @property onToggleValueChange the lambda to be invoked when switch button is pressed
* @property onRestartAppClick the lambda to be invoked when restart app button is pressed
*/
internal data class FeatureTogglesContentState(
val topBar: TopBarWithRefreshUM,
val appVersion: String,
val featureToggles: ImmutableList<TesterFeatureToggle>,
val onToggleValueChange: (String, Boolean) -> Unit,
val onRestartAppClick: () -> Unit,
)

View file

@ -0,0 +1,136 @@
package com.tangem.feature.tester.presentation.featuretoggles.ui
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.TangemSwitch
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.tester.impl.R
import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWithRefresh
import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWithRefreshUM
import com.tangem.feature.tester.presentation.common.components.notification.CustomSetupNotification
import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatureToggle
import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureTogglesContentState
import kotlinx.collections.immutable.persistentListOf
/**
* Screen with feature toggles list
*
* @param state screen state
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun FeatureTogglesScreen(state: FeatureTogglesContentState) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(TangemTheme.colors.background.secondary),
) {
stickyHeader { TopBarWithRefresh(state = state.topBar) }
if (state.topBar.refreshButton.isVisible) {
item(key = "warning_notification", contentType = "warning_notification") {
CustomSetupNotification(
subtitle = resourceReference(
id = R.string.feature_toggles_custom_setup_warning_description,
wrappedList(state.appVersion),
),
modifier = Modifier
.animateItem()
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp),
)
}
}
items(
items = state.featureToggles,
key = TesterFeatureToggle::name,
contentType = { "feature_toggle_item" },
) { featureToggle ->
FeatureToggleItem(
toggle = featureToggle,
onCheckedChange = { isChange -> state.onToggleValueChange(featureToggle.name, isChange) },
)
}
item {
PrimaryButton(
text = stringResourceSafe(id = R.string.restart_app),
onClick = state.onRestartAppClick,
modifier = Modifier
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing16),
)
}
}
}
@Composable
private fun FeatureToggleItem(toggle: TesterFeatureToggle, onCheckedChange: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = TangemTheme.dimens.spacing18,
vertical = TangemTheme.dimens.spacing8,
),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = toggle.name,
modifier = Modifier.weight(1f),
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.body2,
)
TangemSwitch(onCheckedChange = onCheckedChange, checked = toggle.isEnabled)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewFeatureTogglesScreen() {
TangemThemePreview {
var isCustomSetup by remember { mutableStateOf(value = true) }
FeatureTogglesScreen(
state = FeatureTogglesContentState(
topBar = TopBarWithRefreshUM(
titleResId = R.string.feature_toggles,
onBackClick = {},
refreshButton = TopBarWithRefreshUM.RefreshButton(
isVisible = isCustomSetup,
onRefreshClick = { isCustomSetup = false },
),
),
appVersion = "5.15",
featureToggles = persistentListOf(
TesterFeatureToggle(name = "FEATURE_TOGGLE_1", isEnabled = true),
TesterFeatureToggle(name = "FEATURE_TOGGLE_2", isEnabled = false),
),
onToggleValueChange = { _, _ -> isCustomSetup = true },
onRestartAppClick = {},
),
)
}
}

View file

@ -0,0 +1,114 @@
package com.tangem.feature.tester.presentation.featuretoggles.viewmodels
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager
import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.feature.tester.impl.R
import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWithRefreshUM
import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatureToggle
import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureTogglesContentState
import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter
import com.tangem.utils.version.AppVersionProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* ViewModel for screen with list of feature toggles
*
* @property featureTogglesManager manager for getting information about the availability of feature toggles
* @property appVersionProvider app version provider
*
[REDACTED_AUTHOR]
*/
@HiltViewModel
internal class FeatureTogglesViewModel @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
private val appVersionProvider: AppVersionProvider,
) : ViewModel() {
/** Current ui state */
var uiState: FeatureTogglesContentState by mutableStateOf(initState())
private set
private val mutableFeatureTogglesManager: MutableFeatureTogglesManager
get() = requireNotNull(featureTogglesManager as? MutableFeatureTogglesManager) {
"Feature toggle manager must be mutable (debug build type)"
}
/** Setup navigation state property by router [router] and provides app restart method by [appFinisher] */
fun setupInteractions(router: InnerTesterRouter, appFinisher: AppFinisher) {
uiState = uiState.copy(
topBar = uiState.topBar.copy(onBackClick = router::back),
onRestartAppClick = appFinisher::restart,
)
}
private fun initState(): FeatureTogglesContentState {
return FeatureTogglesContentState(
topBar = getConfigSetupState(isPrimarySetup = true),
appVersion = appVersionProvider.versionName,
featureToggles = mutableFeatureTogglesManager.getTesterFeatureToggles(),
onToggleValueChange = ::onToggleValueChange,
onRestartAppClick = {},
)
}
private fun getConfigSetupState(isPrimarySetup: Boolean): TopBarWithRefreshUM {
val isMatchLocalConfig = mutableFeatureTogglesManager.isMatchLocalConfig()
return if (isPrimarySetup) {
TopBarWithRefreshUM(
titleResId = R.string.feature_toggles,
onBackClick = {},
refreshButton = TopBarWithRefreshUM.RefreshButton(
isVisible = !isMatchLocalConfig,
onRefreshClick = ::onRefreshClick,
),
)
} else {
uiState.topBar.copy(
refreshButton = uiState.topBar.refreshButton.copy(isVisible = !isMatchLocalConfig),
)
}
}
private fun onToggleValueChange(name: String, isEnabled: Boolean) {
viewModelScope.launch {
mutableFeatureTogglesManager.changeToggle(name = name, isEnabled = isEnabled)
uiState = uiState.copy(featureToggles = mutableFeatureTogglesManager.getTesterFeatureToggles())
// delay for smoothly update animations
delay(timeMillis = 300)
uiState = uiState.copy(topBar = getConfigSetupState(isPrimarySetup = false))
}
}
private fun onRefreshClick() {
viewModelScope.launch {
mutableFeatureTogglesManager.recoverLocalConfig()
uiState = uiState.copy(
topBar = getConfigSetupState(isPrimarySetup = false),
featureToggles = mutableFeatureTogglesManager.getTesterFeatureToggles(),
)
}
}
private fun MutableFeatureTogglesManager.getTesterFeatureToggles(): ImmutableList<TesterFeatureToggle> {
return this
.getFeatureToggles()
.map { TesterFeatureToggle(it.key, it.value) }
.toImmutableList()
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.feature.tester.presentation.menu.state
import androidx.annotation.StringRes
import com.tangem.feature.tester.impl.R
import kotlinx.collections.immutable.ImmutableSet
/**
* Content state of tester menu screen
*
* @property onBackClick the lambda to be invoked when back button is pressed
* @property buttons list of buttons
* @property onButtonClick the lambda to be invoked when tester button is pressed
*/
data class TesterMenuUM(
val onBackClick: () -> Unit,
val buttons: ImmutableSet<ButtonUM>,
val onButtonClick: (ButtonUM) -> Unit,
) {
enum class ButtonUM(@StringRes val textResId: Int) {
FEATURE_TOGGLES(R.string.feature_toggles),
EXCLUDED_BLOCKCHAINS(R.string.excluded_blockchains),
ENVIRONMENT_TOGGLES(R.string.environment_toggles),
BLOCKCHAIN_PROVIDERS(R.string.blockchain_providers),
TESTER_ACTIONS(R.string.tester_actions),
;
}
}

View file

@ -0,0 +1,85 @@
package com.tangem.feature.tester.presentation.menu.ui
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
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.core.ui.res.TangemThemePreview
import com.tangem.feature.tester.impl.R
import com.tangem.feature.tester.presentation.menu.state.TesterMenuUM
import com.tangem.feature.tester.presentation.menu.state.TesterMenuUM.ButtonUM
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
/**
* Screen with functionality for testers
*
* @param state screen state
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun TesterMenuScreen(state: TesterMenuUM) {
BackHandler(onBack = state.onBackClick)
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary),
) {
stickyHeader {
AppBarWithBackButton(
onBackClick = state.onBackClick,
text = stringResourceSafe(id = R.string.tester_menu),
containerColor = TangemTheme.colors.background.primary,
)
}
items(
items = state.buttons.toImmutableList(),
key = ButtonUM::textResId,
contentType = { "button" },
) { button ->
PrimaryButton(
text = stringResourceSafe(button.textResId),
onClick = { state.onButtonClick(button) },
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
)
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewTesterMenuScreen() {
TangemThemePreview {
TesterMenuScreen(
state = TesterMenuUM(
onBackClick = {},
buttons = persistentSetOf(
ButtonUM.FEATURE_TOGGLES,
ButtonUM.EXCLUDED_BLOCKCHAINS,
ButtonUM.ENVIRONMENT_TOGGLES,
ButtonUM.BLOCKCHAIN_PROVIDERS,
ButtonUM.TESTER_ACTIONS,
),
onButtonClick = {},
),
)
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.feature.tester.presentation.navigation
import android.content.Context
import android.content.Intent
import androidx.navigation.NavController
import com.tangem.feature.tester.presentation.TesterActivity
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import javax.inject.Inject
/**
* Implementation of router for tester feature
*
* @property context activity context
*
[REDACTED_AUTHOR]
*/
@ActivityScoped
internal class DefaultTesterRouter @Inject constructor(
@ActivityContext private val context: Context,
) : InnerTesterRouter {
private var navController: NavController? = null
override fun getEntryIntent(): Intent {
return Intent(context, TesterActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
override fun setNavController(navController: NavController) {
this.navController = navController
}
override fun open(screen: TesterScreen) {
navController?.navigate(screen.name)
}
override fun back() {
navController?.popBackStack()
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.feature.tester.presentation.navigation
import androidx.navigation.NavController
import com.tangem.features.tester.api.TesterRouter
/**
* Inner feature router
*
[REDACTED_AUTHOR]
*/
internal interface InnerTesterRouter : TesterRouter {
/** Set up a navigation controller that bound to tester navigation graph */
fun setNavController(navController: NavController)
/** Open specified screen [screen] */
fun open(screen: TesterScreen)
/** Open last screen */
fun back()
}

View file

@ -0,0 +1,15 @@
package com.tangem.feature.tester.presentation.navigation
/**
* Tester feature screens
*
[REDACTED_AUTHOR]
*/
internal enum class TesterScreen {
MENU,
FEATURE_TOGGLES,
ENVIRONMENTS_TOGGLES,
TESTER_ACTIONS,
EXCLUDED_BLOCKCHAINS,
BLOCKCHAIN_PROVIDERS,
}

View file

@ -0,0 +1,41 @@
package com.tangem.feature.tester.presentation.providers.entity
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.network.providers.ProviderType
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWithRefreshUM
import kotlinx.collections.immutable.ImmutableList
internal data class BlockchainProvidersUM(
val topBar: TopBarWithRefreshUM,
val searchBar: SearchBarUM,
val blockchainProviders: ImmutableList<ProvidersUM>,
val onRestartAppClick: () -> Unit,
) {
@Immutable
data class ProvidersUM(
val blockchainId: String,
val blockchainName: String,
val blockchainSymbol: String,
val providers: ImmutableList<ProviderUM>,
val isExpanded: Boolean,
val onDrop: (prev: Int, current: Int) -> Unit,
val addPublicProviderDialog: AddPublicProviderDialogUM,
)
@Immutable
data class ProviderUM(val type: ProviderType) {
val name: String = when (type) {
is ProviderType.Public -> type.url
else -> type::class.java.simpleName
}
}
data class AddPublicProviderDialogUM(
val hasError: Boolean,
val onValueChange: (String) -> Unit,
val onSaveClick: (String) -> Unit,
)
}

View file

@ -0,0 +1,444 @@
package com.tangem.feature.tester.presentation.providers.ui
import android.content.ClipData
import android.content.ClipDescription
import android.view.View
import androidx.compose.animation.*
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.*
import androidx.compose.foundation.draganddrop.dragAndDropSource
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.draganddrop.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.blockchain.common.network.providers.ProviderType
import com.tangem.core.ui.components.AdditionalTextInputDialogUM
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.TextInputDialog
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.rows.RowText
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.tester.impl.R
import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWithRefresh
import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWithRefreshUM
import com.tangem.feature.tester.presentation.common.components.notification.CustomSetupNotification
import com.tangem.feature.tester.presentation.providers.entity.BlockchainProvidersUM
import com.tangem.feature.tester.presentation.providers.entity.BlockchainProvidersUM.ProviderUM
import com.tangem.feature.tester.presentation.providers.entity.BlockchainProvidersUM.ProvidersUM
import kotlinx.collections.immutable.persistentListOf
private const val CHEVRON_ROTATION_EXPANDED = 180f
private const val CHEVRON_ROTATION_COLLAPSED = 0f
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun BlockchainProvidersScreen(state: BlockchainProvidersUM) {
Box(
modifier = Modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary),
) {
LazyColumn(contentPadding = PaddingValues(bottom = 64.dp)) {
stickyHeader {
Header(topBar = state.topBar, searchBar = state.searchBar)
}
if (state.topBar.refreshButton.isVisible) {
item(key = "notification", contentType = "notification") {
CustomSetupNotification(
subtitle = resourceReference(
id = R.string.blockchain_providers_custom_setup_warning_description,
),
modifier = Modifier
.animateItem()
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp),
)
}
}
items(
items = state.blockchainProviders,
key = ProvidersUM::blockchainId,
contentType = { "BlockchainProviders" },
itemContent = { BlockchainProvidersItem(it) },
)
}
PrimaryButton(
text = stringResourceSafe(id = R.string.restart_app),
onClick = state.onRestartAppClick,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing16),
)
}
}
@Composable
private fun Header(topBar: TopBarWithRefreshUM, searchBar: SearchBarUM) {
Column(
modifier = Modifier.background(TangemTheme.colors.background.primary),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
TopBarWithRefresh(state = topBar)
SearchBar(
state = searchBar,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = TangemTheme.dimens.spacing12),
)
}
}
@Composable
private fun BlockchainProvidersItem(state: ProvidersUM) {
var isExpanded by remember { mutableStateOf(state.isExpanded) }
ExpandableBlockchainRow(
state = state,
content = { Providers(state = state, isExpanded = isExpanded) },
onExpandStateChange = { isExpanded = !isExpanded },
)
}
@Composable
private fun ExpandableBlockchainRow(
state: ProvidersUM,
content: @Composable () -> Unit,
onExpandStateChange: () -> Unit,
) {
Column {
BlockchainRow(state = state, onExpandStateChange = onExpandStateChange)
content()
}
}
@Composable
private fun BlockchainRow(state: ProvidersUM, onExpandStateChange: () -> Unit) {
Row(
modifier = Modifier
.clickable { onExpandStateChange() }
.padding(horizontal = 16.dp)
.fillMaxWidth()
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(id = getActiveIconRes(state.blockchainId)),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size36),
)
RowText(
mainText = state.blockchainName,
secondText = state.blockchainSymbol,
accentMainText = true,
accentSecondText = false,
modifier = Modifier.padding(horizontal = 8.dp),
)
}
val rotation by animateFloatAsState(
targetValue = if (state.isExpanded) {
CHEVRON_ROTATION_EXPANDED
} else {
CHEVRON_ROTATION_COLLAPSED
},
label = "chevron_rotation",
)
Icon(
modifier = Modifier
.rotate(rotation)
.size(TangemTheme.dimens.size24),
painter = painterResource(id = R.drawable.ic_chevron_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
}
@Composable
private fun Providers(state: ProvidersUM, isExpanded: Boolean) {
AnimatedVisibility(
visible = isExpanded,
modifier = Modifier.padding(horizontal = 16.dp),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
label = "providers_visibility",
) {
Column(
modifier = Modifier.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
state.providers.fastForEachIndexed { index, provider ->
key("${provider.name}_$index") {
ProviderItem(
index = index,
state = provider,
onDrop = { prev, new -> state.onDrop(prev, new) },
)
}
}
AddProviderButton(state = state.addPublicProviderDialog)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ProviderItem(index: Int, state: ProviderUM, onDrop: (Int, Int) -> Unit) {
var isActive by remember(index) { mutableStateOf(false) }
val color by animateColorAsState(
targetValue = if (isActive) {
TangemTheme.colors.icon.accent
} else {
TangemTheme.colors.icon.inactive
},
label = "provider_name_background_color",
)
val target = remember {
ProvidersDnDTarget(
index = index,
onActiveStateChange = { isActive = it },
onDrop = onDrop,
)
}
ProviderItemContainer {
ProviderIndex(index = index)
Box(
modifier = Modifier
.background(color = color, shape = RoundedCornerShape(16.dp))
.padding(vertical = 4.dp, horizontal = 8.dp)
.dragAndDropSource {
detectTapGestures(
onLongPress = {
startTransfer(
DragAndDropTransferData(
clipData = ClipData.newPlainText("provider index", index.toString()),
flags = View.DRAG_FLAG_GLOBAL,
),
)
},
)
}
.dragAndDropTarget(
shouldStartDragAndDrop = { event ->
event
.mimeTypes()
.contains(ClipDescription.MIMETYPE_TEXT_PLAIN)
},
target = target,
),
) {
Text(
text = state.name,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.body2,
)
}
}
}
@Composable
private fun ProviderIndex(index: Int) {
Text(
text = "${index + 1}.",
color = TangemTheme.colors.text.accent,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.body2,
)
}
@Composable
private fun AddProviderButton(state: BlockchainProvidersUM.AddPublicProviderDialogUM) {
var isDialogVisible by remember { mutableStateOf(value = false) }
ProviderItemContainer {
Box(
modifier = Modifier
.clip(shape = RoundedCornerShape(16.dp))
.clickable(onClick = { isDialogVisible = true })
.weight(1f)
.border(
border = BorderStroke(width = 2.dp, color = TangemTheme.colors.icon.accent),
shape = RoundedCornerShape(16.dp),
)
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = R.drawable.ic_plus_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
}
if (isDialogVisible) {
AddPublicProviderDialog(
state = state,
onDismiss = { isDialogVisible = false },
)
}
}
@Composable
private fun AddPublicProviderDialog(state: BlockchainProvidersUM.AddPublicProviderDialogUM, onDismiss: () -> Unit) {
var value by remember { mutableStateOf(value = TextFieldValue()) }
TextInputDialog(
fieldValue = value,
confirmButton = DialogButtonUM(
title = stringResourceSafe(R.string.common_save),
enabled = value.text.isNotBlank() && !state.hasError,
onClick = {
state.onSaveClick(
buildString {
append(value.text.trim())
if (!endsWith("/")) append("/")
},
)
onDismiss()
},
),
onDismissDialog = onDismiss,
onValueChange = {
value = it
state.onValueChange(it.text)
},
textFieldParams = AdditionalTextInputDialogUM(
label = "Url",
placeholder = "https://...",
isError = state.hasError,
errorText = "Invalid url",
),
title = "Public provider",
dismissButton = DialogButtonUM(
title = stringResourceSafe(id = R.string.common_cancel),
onClick = onDismiss,
),
)
}
@Composable
private fun ProviderItemContainer(content: @Composable RowScope.() -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}
private class ProvidersDnDTarget(
private val index: Int,
private val onActiveStateChange: (Boolean) -> Unit,
private val onDrop: (Int, Int) -> Unit,
) : DragAndDropTarget {
override fun onEntered(event: DragAndDropEvent) {
super.onEntered(event)
onActiveStateChange(true)
}
override fun onExited(event: DragAndDropEvent) {
super.onExited(event)
onActiveStateChange(false)
}
override fun onEnded(event: DragAndDropEvent) {
super.onEnded(event)
onActiveStateChange(false)
}
override fun onDrop(event: DragAndDropEvent): Boolean {
val new = event.toAndroidDragEvent().clipData.getItemAt(0).text.toString().toInt()
if (index == new) return false
onDrop(new, index)
return true
}
}
@Preview
@Composable
private fun Preview_BlockchainProvidersScreen() {
TangemThemePreview {
BlockchainProvidersScreen(
state = BlockchainProvidersUM(
topBar = TopBarWithRefreshUM(
titleResId = R.string.blockchain_providers,
onBackClick = {},
refreshButton = TopBarWithRefreshUM.RefreshButton(
isVisible = true,
onRefreshClick = { },
),
),
searchBar = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = {},
),
blockchainProviders = persistentListOf(
ProvidersUM(
blockchainId = "ETH",
blockchainName = "Ethereum",
blockchainSymbol = "ETH",
isExpanded = false,
onDrop = { _, _ -> },
addPublicProviderDialog = BlockchainProvidersUM.AddPublicProviderDialogUM(
hasError = false,
onValueChange = {},
onSaveClick = {},
),
providers = persistentListOf(
ProviderUM(type = ProviderType.NowNodes),
ProviderUM(type = ProviderType.GetBlock),
),
),
),
onRestartAppClick = {},
),
)
}
}

View file

@ -0,0 +1,231 @@
package com.tangem.feature.tester.presentation.providers.viewmodel
import androidx.core.util.PatternsCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.network.providers.ProviderType
import com.tangem.blockchainsdk.providers.BlockchainProvidersTypesManager
import com.tangem.blockchainsdk.providers.MutableBlockchainProvidersTypesManager
import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.feature.tester.impl.R
import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWithRefreshUM
import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter
import com.tangem.feature.tester.presentation.providers.entity.BlockchainProvidersUM
import com.tangem.feature.tester.presentation.providers.entity.BlockchainProvidersUM.*
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class BlockchainProvidersViewModel @Inject constructor(
blockchainProvidersTypesManager: BlockchainProvidersTypesManager,
) : ViewModel() {
val state: StateFlow<BlockchainProvidersUM> get() = _state.asStateFlow()
private val _state = MutableStateFlow(
value = BlockchainProvidersUM(
topBar = TopBarWithRefreshUM(
titleResId = R.string.blockchain_providers,
onBackClick = {},
refreshButton = TopBarWithRefreshUM.RefreshButton(isVisible = false, onRefreshClick = ::onRefreshClick),
),
searchBar = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
onQueryChange = {
searchQuery.value = it.trim()
},
isActive = false,
onActiveChange = {},
),
blockchainProviders = persistentListOf(),
onRestartAppClick = {},
),
)
private val searchQuery = MutableStateFlow(value = "")
private val manager = blockchainProvidersTypesManager as MutableBlockchainProvidersTypesManager
init {
subscribeOnBlockchainProviderTypesUpdates()
}
fun setupNavigation(router: InnerTesterRouter, appFinisher: AppFinisher) {
_state.update { state ->
state.copy(
topBar = state.topBar.copy(onBackClick = router::back),
onRestartAppClick = appFinisher::restart,
)
}
}
private fun subscribeOnBlockchainProviderTypesUpdates() {
combine(
flow = manager.get(),
flow2 = searchQuery,
transform = { blockchainProviderTypes, query -> blockchainProviderTypes to query },
)
.onEach { (blockchainProviderTypes, query) ->
val isChanged = !manager.isMatchWithMerged()
_state.update { state ->
state.copy(
topBar = state.topBar.copy(
refreshButton = state.topBar.refreshButton.copy(isVisible = isChanged),
),
searchBar = state.searchBar.copy(query = query),
blockchainProviders = blockchainProviderTypes
.filter {
it.key.fullName.contains(other = query, ignoreCase = true) ||
it.key.currency.contains(other = query, ignoreCase = true)
}
.toProvidersUM(),
)
}
}
.launchIn(viewModelScope)
}
private fun Map<Blockchain, List<ProviderType>>.toProvidersUM(): ImmutableList<ProvidersUM> {
return map {
val (blockchain, providers) = it
ProvidersUM(
blockchainId = blockchain.id,
blockchainName = blockchain.fullName,
blockchainSymbol = blockchain.currency,
providers = providers.map(BlockchainProvidersUM::ProviderUM).toImmutableList(),
isExpanded = false,
onDrop = { prev, current -> onDrop(blockchain.id, prev, current) },
addPublicProviderDialog = AddPublicProviderDialogUM(
hasError = false,
onValueChange = { onPublicProviderUrlChange(id = blockchain.id, url = it) },
onSaveClick = { onAddProviderClick(id = blockchain.id, url = it) },
),
)
}
.sortedBy(ProvidersUM::blockchainName)
.toImmutableList()
}
private fun onRefreshClick() {
viewModelScope.launch {
manager.recoverInitialState()
}
}
private fun onDrop(id: String, prev: Int, current: Int) {
val providers = _state.value
.blockchainProviders.first { it.blockchainId == id }
.providers.changeItemOrder(prev, current)
viewModelScope.launch {
_state.update {
it.copyProvidersUM(blockchainId = id) {
copy(providers = providers.toImmutableList())
}
}
manager.update(
blockchain = Blockchain.fromId(id),
providers = providers.map(ProviderUM::type),
)
}
}
private fun ImmutableList<ProviderUM>.changeItemOrder(prev: Int, current: Int): ImmutableList<ProviderUM> {
return when {
prev > current && prev - current == 1 -> moveUpOneElement(prev, current)
current > prev && current - prev == 1 -> moveDownOneElement(prev, current)
else -> replaceElements(prev, current)
}
.toImmutableList()
}
// Before: [a, b, c, d, e]; previous = 2 (c), current = 1 (b)
// After: [a, c, b, d, e]
// Calculation:
// 1) [0..current) = [0..1) = [a]
// 2) [list[prev], list[current]] = [list[2], list[1]] = [c, b]
// 3) [(prev+1)..list.size) = [3..5) = [d, e]
// 4) [a] + [c, b] + [d, e] = [a, c, b, d, e]
private fun List<ProviderUM>.moveUpOneElement(prev: Int, current: Int): List<ProviderUM> {
return subList(fromIndex = 0, toIndex = current) +
listOf(this[prev], this[current]) +
subList(fromIndex = prev + 1, toIndex = this.size)
}
// Before: [a, b, c, d, e]; previous = 2 (c), current = 3 (d)
// After: [a, b, d, c, e]
// Calculation:
// 1) [0..prev) = [0..2) = [a, b]
// 2) [list[current], list[prev]] = [list[3], list[2]] = [d, c]
// 3) [(current+1)..list.size) = [4..5) = [e]
// 4) [a, b] + [d, c] + [e] = [a, b, d, c, e]
private fun List<ProviderUM>.moveDownOneElement(prev: Int, current: Int): List<ProviderUM> {
return subList(fromIndex = 0, toIndex = prev) +
listOf(this[current], this[prev]) +
subList(fromIndex = current + 1, toIndex = size)
}
// Before: [a, b, c, d, e]; previous = 0 (a), current = 4 (e)
// After: [e, b, c, d, a]
private fun List<ProviderUM>.replaceElements(prev: Int, current: Int): List<ProviderUM> {
val mutableList = toMutableList()
mutableList[prev] = this[current]
mutableList[current] = this[prev]
return mutableList
}
private fun onPublicProviderUrlChange(id: String, url: String) {
_state.update {
it.copyProvidersUM(blockchainId = id) {
copy(
addPublicProviderDialog = addPublicProviderDialog.copy(
hasError = !PatternsCompat.WEB_URL.matcher(url).matches(),
),
)
}
}
}
private fun onAddProviderClick(id: String, url: String) {
val providers = _state.value
.blockchainProviders.first { it.blockchainId == id }
.providers
viewModelScope.launch {
manager.update(
blockchain = Blockchain.fromId(id),
providers = listOf(ProviderType.Public(url = url)) + providers.map(ProviderUM::type),
)
}
}
private fun BlockchainProvidersUM.copyProvidersUM(
blockchainId: String,
update: ProvidersUM.() -> ProvidersUM,
): BlockchainProvidersUM {
return copy(
blockchainProviders = _state.value.blockchainProviders.map {
if (it.blockchainId == blockchainId) {
it.update()
} else {
it
}
}
.toImmutableList(),
)
}
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="tester_menu" translatable="false">Tester menu</string>
<string name="feature_toggles" translatable="false">Feature toggles</string>
<string name="custom_setup_warning_title" translatable="false">Custom setup</string>
<string name="feature_toggles_custom_setup_warning_description" translatable="false">Toggles differs from v%s config</string>
<string name="blockchain_providers_custom_setup_warning_description" translatable="false">Providers differs from release config</string>
<string name="restart_app" translatable="false">Restart app</string>
<string name="environment_toggles" translatable="false">Environment toggles</string>
<string name="tester_actions" translatable="false">Tester actions</string>
<string name="hide_all_currencies" translatable="false">Hide all currencies</string>
<string name="toggle_app_theme" translatable="false">Toggle app theme - %s</string>
<string name="excluded_blockchains" translatable="false">Excluded blockchains</string>
<string name="excluded_blockchains_search_placeholder" translatable="false">Filter by name or symbol</string>
<string name="blockchain_providers" translatable="false">Blockchain providers</string>
<string name="share_logs" translatable="false">Share logs</string>
</resources>