Updated on 2026-08-14
This commit is contained in:
parent
330ae73357
commit
e7efe7b76a
4636 changed files with 234864 additions and 63507 deletions
1
features/tester/impl/.gitignore
vendored
Normal file
1
features/tester/impl/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
59
features/tester/impl/build.gradle.kts
Normal file
59
features/tester/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
10
features/tester/impl/src/main/AndroidManifest.xml
Normal file
10
features/tester/impl/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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?)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
17
features/tester/impl/src/main/res/values/strings.xml
Normal file
17
features/tester/impl/src/main/res/values/strings.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue