Updated on 2026-08-14

This commit is contained in:
Tangem 2025-01-09 20:37:33 +03:00
commit 8e42589a00
456 changed files with 6580 additions and 3738 deletions

View file

@ -10,5 +10,13 @@
{
"name": "NEXA/test",
"version": "undefined"
},
{
"name": "fact0rn",
"version": "undefined"
},
{
"name": "vanar-chain",
"version": "undefined"
}
]

View file

@ -11,10 +11,6 @@
"name": "IS_ETHEREUM_EIP_1559_ENABLED",
"version": "5.17.0"
},
{
"name": "MIGRATE_USER_COUNTRY_CODE_ENABLED",
"version": "5.17.0"
},
{
"name": "ONRAMP_ENABLED",
"version": "undefined"
@ -26,5 +22,9 @@
{
"name": "ONBOARDING_CODE_REFACTORING_ENABLED",
"version": "undefined"
},
{
"name": "NAVIGATION_REFACTORING",
"version": "undefined"
}
]

View file

@ -24,6 +24,7 @@ sealed class ApiConfig {
Express,
TangemTech,
StakeKit,
TangemVisaAuth,
}
private fun initializeId(): ID {
@ -31,6 +32,7 @@ sealed class ApiConfig {
is Express -> ID.Express
is TangemTech -> ID.TangemTech
is StakeKit -> ID.StakeKit
is TangemVisaAuth -> ID.TangemVisaAuth
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.datasource.api.common.config
import com.tangem.utils.Provider
internal class TangemVisaAuth : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.STAGE
override val environmentConfigs = listOf(
createStageEnvironment(),
)
private fun createStageEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.STAGE,
baseUrl = "https://api-s.tangem.org/",
headers = createHeaders(),
)
private fun createHeaders() = mapOf<String, Provider<String>>()
}

View file

@ -1,44 +0,0 @@
package com.tangem.datasource.api.tangemTech
import com.tangem.datasource.api.common.MoshiConverter
import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory
import com.tangem.datasource.utils.RequestHeader
import com.tangem.datasource.utils.RequestHeader.AuthenticationHeader
import com.tangem.datasource.utils.RequestHeader.CacheControlHeader
import com.tangem.datasource.utils.addHeaders
import com.tangem.datasource.utils.addLoggers
import okhttp3.OkHttpClient
import retrofit2.Retrofit
/**
[REDACTED_AUTHOR]
*/
// TODO("Remove after removing Redux")
@Deprecated("Use TangemTechApi")
object TangemTechService {
var api: TangemTechApi = createApi()
private set
private const val TANGEM_TECH_BASE_URL = "https://api.tangem-tech.com/v1/"
fun addAuthenticationHeader(header: AuthenticationHeader) {
api = createApi(header)
}
private fun createApi(header: RequestHeader? = null): TangemTechApi {
val headers = mutableListOf<RequestHeader>(CacheControlHeader).apply { header?.let(::add) }
return Retrofit.Builder()
.addConverterFactory(MoshiConverter.networkMoshiConverter)
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
.baseUrl(TANGEM_TECH_BASE_URL)
.client(
OkHttpClient.Builder()
.addHeaders(*headers.toTypedArray())
.addLoggers()
.build(),
)
.build()
.create(TangemTechApi::class.java)
}
}

View file

@ -0,0 +1,5 @@
package com.tangem.datasource.api.visa
interface TangemVisaApi {
// TODO
}

View file

@ -0,0 +1,47 @@
package com.tangem.datasource.api.visa
import com.tangem.datasource.api.visa.models.response.GenerateNonceResponse
import com.tangem.datasource.api.visa.models.response.JWTResponse
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.Headers
import retrofit2.http.POST
interface TangemVisaAuthApi {
@FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("auth/clients/mobile-app-android/nonce-challenge")
suspend fun generateNonceByWalletAddress(
@Field("customer_id") customerId: String? = null,
@Field("customer_wallet_address") customerWalletAddress: String,
): GenerateNonceResponse
@FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("auth/clients/mobile-app-android/nonce-challenge")
suspend fun generateNonceByCard(
@Field("card_id") cardId: String,
@Field("card_public_key") cardPublicKey: String,
): GenerateNonceResponse
@FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("auth/protocol/openid-connect/token")
suspend fun getAccessToken(
@Field("client_id") clientId: String = "mobile-app-android",
@Field("grant_type") grantType: String = "password",
@Field("session_id") sessionId: String,
@Field("signature") signature: String,
@Field("salt") salt: String?,
): JWTResponse
@FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("auth/protocol/openid-connect/token")
suspend fun refreshAccessToken(
@Field("client_id") clientId: String = "mobile-app-android",
@Field("grant_type") grantType: String = "refresh_token",
@Field("refresh_token") refreshToken: String,
): JWTResponse
}

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.visa.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GenerateNonceResponse(
@Json(name = "nonce") val nonce: String,
@Json(name = "session_id") val sessionId: String,
)

View file

@ -0,0 +1,16 @@
package com.tangem.datasource.api.visa.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class JWTResponse(
@Json(name = "access_token") val accessToken: String,
@Json(name = "expires_in") val expiresIn: Int,
@Json(name = "refresh_expires_in") val refreshExpiresIn: Int,
@Json(name = "refresh_token") val refreshToken: String,
@Json(name = "token_type") val tokenType: String,
@Json(name = "not-before-policy") val notBeforePolicy: Int,
@Json(name = "session_state") val sessionState: String,
@Json(name = "scope") val scope: String,
)

View file

@ -1,7 +1,7 @@
package com.tangem.datasource.di
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.*
import com.tangem.datasource.api.common.config.Express
import com.tangem.datasource.api.common.config.StakeKit
import com.tangem.datasource.api.common.config.TangemTech
@ -39,4 +39,8 @@ internal object ApiConfigsModule {
@IntoSet
fun provideTangemTechConfig(appVersionProvider: AppVersionProvider, authProvider: AuthProvider): ApiConfig =
TangemTech(appVersionProvider, authProvider)
@Provides
@IntoSet
fun provideTangemVisaConfig(): ApiConfig = TangemVisaAuth()
}

View file

@ -15,6 +15,7 @@ import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.TangemTechApiV2
import com.tangem.datasource.api.visa.TangemVisaAuthApi
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.utils.*
@ -169,6 +170,21 @@ internal object NetworkModule {
)
}
@Provides
@Singleton
fun provideTangemVisaApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
apiConfigsManager: ApiConfigsManager,
): TangemVisaAuthApi {
return createApi<TangemVisaAuthApi>(
id = ApiConfig.ID.TangemVisaAuth,
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
)
}
@Deprecated("use createApi instead")
private inline fun <reified T> provideTangemTechApiInternal(
moshi: Moshi,

View file

@ -87,6 +87,19 @@ internal object BlockchainSDKConfigConverter : Converter<EnvironmentConfigModel,
base = GetBlockAccessToken(jsonRpc = accessTokens.base?.jsonRPC),
blast = GetBlockAccessToken(jsonRpc = accessTokens.blast?.jsonRPC),
filecoin = GetBlockAccessToken(jsonRpc = accessTokens.filecoin?.jsonRPC),
arbitrum = GetBlockAccessToken(jsonRpc = accessTokens.arbitrum?.jsonRPC),
bitcoinCash = GetBlockAccessToken(
jsonRpc = accessTokens.bitcoinCash?.jsonRPC,
blockBookRest = accessTokens.bitcoinCash?.blockBookRest,
),
kusama = GetBlockAccessToken(jsonRpc = accessTokens.kusama?.jsonRPC),
moonbeam = GetBlockAccessToken(jsonRpc = accessTokens.moonbeam?.jsonRPC),
optimism = GetBlockAccessToken(jsonRpc = accessTokens.optimism?.jsonRPC),
polkadot = GetBlockAccessToken(jsonRpc = accessTokens.polkadot?.jsonRPC),
shibarium = GetBlockAccessToken(jsonRpc = accessTokens.shibarium?.jsonRPC),
sui = GetBlockAccessToken(jsonRpc = accessTokens.sui?.jsonRPC),
telos = GetBlockAccessToken(jsonRpc = accessTokens.telos?.jsonRPC),
tezos = GetBlockAccessToken(rest = accessTokens.tezos?.rest),
)
}
}

View file

@ -66,6 +66,16 @@ data class GetBlockAccessTokens(
@Json(name = "base") val base: GetBlockToken?,
@Json(name = "blast") val blast: GetBlockToken?,
@Json(name = "filecoin") val filecoin: GetBlockToken?,
@Json(name = "arbitrum") val arbitrum: GetBlockToken?,
@Json(name = "bitcoinCash") val bitcoinCash: GetBlockToken?,
@Json(name = "kusama") val kusama: GetBlockToken?,
@Json(name = "moonbeam") val moonbeam: GetBlockToken?,
@Json(name = "optimism") val optimism: GetBlockToken?,
@Json(name = "polkadot") val polkadot: GetBlockToken?,
@Json(name = "shibarium") val shibarium: GetBlockToken?,
@Json(name = "sui") val sui: GetBlockToken?,
@Json(name = "telos") val telos: GetBlockToken?,
@Json(name = "tezos") val tezos: GetBlockToken?,
)
@JsonClass(generateAdapter = true)

View file

@ -85,6 +85,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
is Express -> createExpressModel()
is TangemTech -> createTangemTechModel()
is StakeKit -> createStakeKitModel()
is TangemVisaAuth -> createVisaAuthModel()
}
}
@ -172,6 +173,16 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
)
}
private fun createVisaAuthModel(): Model {
return Model(
id = ApiConfig.ID.TangemVisaAuth,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.STAGE,
baseUrl = "https://api-s.tangem.org/",
),
)
}
private fun String.checkHeaderValueOrEmpty(): String {
for (i in this.indices) {
val c = this[i]

View file

@ -7,8 +7,6 @@ import com.tangem.core.decompose.navigation.AppNavigationProvider
import com.tangem.core.decompose.navigation.DefaultAppNavigationProvider
import com.tangem.core.decompose.navigation.DefaultRouter
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.DefaultUiMessageSender
import com.tangem.core.decompose.ui.UiMessageHandler
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.decompose.utils.ComponentCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -16,9 +14,9 @@ import kotlinx.coroutines.CoroutineScope
class DefaultAppComponentContext(
componentContext: ComponentContext,
messageHandler: UiMessageHandler,
override val dispatchers: CoroutineDispatcherProvider,
override val hiltComponentBuilder: DecomposeComponent.Builder,
override val messageSender: UiMessageSender,
private val replaceRouter: Router? = null,
) : AppComponentContext, ComponentContext by componentContext {
@ -26,8 +24,6 @@ class DefaultAppComponentContext(
override val componentScope: CoroutineScope = ComponentCoroutineScope(lifecycle, dispatchers)
override val messageSender: UiMessageSender = DefaultUiMessageSender(messageHandler)
override val navigationProvider: AppNavigationProvider
get() = instanceKeeper.getOrCreate { DefaultAppNavigationProvider() }

View file

@ -3,4 +3,7 @@ package com.tangem.core.decompose.di
import javax.inject.Qualifier
@Qualifier
annotation class RootAppComponentContext
annotation class RootAppComponentContext
@Qualifier
annotation class GlobalUiMessageSender

View file

@ -1,6 +1,6 @@
package com.tangem.core.decompose.ui
internal class DefaultUiMessageSender(
class DefaultUiMessageSender(
private val handler: UiMessageHandler,
) : UiMessageSender {

View file

@ -6,4 +6,17 @@ plugins {
android {
namespace = "com.tangem.core.res"
}
dependencies {
implementation(projects.core.utils)
implementation(deps.timber)
// region Firebase libraries
implementation(platform(deps.firebase.bom))
implementation(deps.firebase.analytics)
implementation(deps.firebase.crashlytics)
// endregion
}

View file

@ -0,0 +1,82 @@
package com.tangem.core.res
import android.content.res.Resources
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.tangem.utils.SupportedLanguages
import timber.log.Timber
/**
* Get a string resource safely or the resource name if an exception is thrown
*
* @param id resource id
*/
fun Resources.getStringSafe(@StringRes id: Int): String {
return runCatching { getString(id) }
.getOrResourceName(resources = this, id)
}
/**
* Get a string resource safely or the resource name if an exception is thrown
*
* @param id resource id
* @param formatArgs format args
*/
fun Resources.getStringSafe(@StringRes id: Int, vararg formatArgs: Any): String {
return runCatching { getString(id, *formatArgs) }
.recoverCatching {
// If something goes wrong, returns the resource without arguments
val string = getString(id)
reportIssue(resources = this, id, *formatArgs)
string
}
.getOrResourceName(resources = this, id, *formatArgs)
}
/**
* Get a plural string resource safely or the resource name if an exception is thrown
*
* @param id resource id
* @param count count
*/
fun Resources.getPluralStringSafe(@PluralsRes id: Int, count: Int): String {
return runCatching { getQuantityString(id, count) }
.getOrResourceName(resources = this, id)
}
/**
* Get a plural string resource safely or the resource name if an exception is thrown
*
* @param id resource id
* @param count count
* @param formatArgs format args
*/
fun Resources.getPluralStringSafe(@PluralsRes id: Int, count: Int, vararg formatArgs: Any): String {
return runCatching { getQuantityString(id, count, *formatArgs) }
.getOrResourceName(resources = this, id, *formatArgs)
}
private fun Result<String>.getOrResourceName(resources: Resources, id: Int, vararg formatArgs: Any): String {
return getOrElse {
reportIssue(resources, id, formatArgs)
// If something still goes wrong, returns the resource name
resources.getResourceEntryName(id)
}
}
private fun reportIssue(resources: Resources, id: Int, vararg formatArgs: Any) {
val exception = IllegalStateException(
"An error occurred while parsing the string:\n" +
"\tname: R.string.${resources.getResourceEntryName(id)}\n" +
"\targs: ${formatArgs.joinToString(prefix = "[", postfix = "]") { it.toString() }}\n" +
"\tlocale: ${SupportedLanguages.getCurrentSupportedLanguageCode()}\n",
)
Timber.tag("Resources").e(exception)
FirebaseCrashlytics.getInstance().recordException(exception)
}

View file

@ -9,6 +9,19 @@ import androidx.compose.runtime.Stable
@Stable
interface ClipboardManager {
/** Copies to clipboard [text] with optional [label] */
fun setText(label: String = "", text: String)
/**
* Copies to clipboard [text] with optional [label]
*
* @param text text
* @param isSensitive flag that determines whether the text is sensitive
* @param label label
*/
fun setText(text: String, isSensitive: Boolean, label: String = "")
/**
* Get text (text/plain) from clipboard
*
* @param default default value
*/
fun getText(default: String? = null): String?
}

View file

@ -16,7 +16,6 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -27,6 +26,7 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.components.SelctorDialogParamsProvider.SelectorDialogParams
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.components.fields.SimpleDialogTextField
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.ImmutableList
@ -318,14 +318,14 @@ private fun DialogButtons(
) {
if (dismissButton != null) {
DialogButton(
text = dismissButton.title ?: stringResource(id = R.string.common_cancel),
text = dismissButton.title ?: stringResourceSafe(id = R.string.common_cancel),
warning = dismissButton.warning,
enabled = dismissButton.enabled,
onClick = dismissButton.onClick,
)
}
DialogButton(
text = confirmButton.title ?: stringResource(id = R.string.common_ok),
text = confirmButton.title ?: stringResourceSafe(id = R.string.common_ok),
warning = confirmButton.warning,
enabled = confirmButton.enabled,
onClick = confirmButton.onClick,

View file

@ -2,7 +2,6 @@ package com.tangem.core.ui.components.bottomsheets
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
@ -99,7 +98,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> DefaultBottomSheet(
crossinline title: @Composable (BoxScope.(T) -> Unit),
crossinline content: @Composable (ColumnScope.(T) -> Unit),
) {
var isVisible by remember { mutableStateOf(value = config.isShow) }
var isVisible by remember { mutableStateOf(value = config.isShown) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded)
if (isVisible && config.content is T) {
@ -114,8 +113,8 @@ inline fun <reified T : TangemBottomSheetConfigContent> DefaultBottomSheet(
)
}
LaunchedEffect(key1 = config.isShow) {
if (config.isShow) {
LaunchedEffect(key1 = config.isShown) {
if (config.isShown) {
isVisible = true
} else {
sheetState.collapse { isVisible = false }

View file

@ -3,12 +3,12 @@ package com.tangem.core.ui.components.bottomsheets
/**
* Tangem bottom sheet config
*
* @property isShow flag that determine if bottom sheet is shown
* @property isShown flag that determine if bottom sheet is shown
* @property onDismissRequest lambda be invoked when bottom sheet is dismissed
* @property content content config
*/
data class TangemBottomSheetConfig(
val isShow: Boolean,
val isShown: Boolean,
val onDismissRequest: () -> Unit,
val content: TangemBottomSheetConfigContent,
) {

View file

@ -0,0 +1,156 @@
package com.tangem.core.ui.components.bottomsheets.message
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
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.text.style.TextAlign
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.core.ui.R
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.isNullOrEmpty
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@Composable
fun MessageBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(config) { notification: MessageBottomSheetUM ->
Content(model = notification)
}
}
@Composable
internal fun Content(model: MessageBottomSheetUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(modifier = Modifier)
if (model.iconResId != null) {
Icon(
modifier = Modifier.size(48.dp),
painter = painterResource(model.iconResId),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
)
}
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!model.title.isNullOrEmpty()) {
Text(
text = model.title.resolveReference(),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
}
Text(
text = model.message.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
}
Column(
modifier = Modifier
.padding(bottom = 16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (model.primaryAction != null) {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = model.primaryAction.text.resolveReference(),
enabled = model.primaryAction.enabled,
onClick = model.primaryAction.onClick,
)
}
if (model.secondaryAction != null) {
SecondaryButton(
modifier = Modifier.fillMaxWidth(),
text = model.secondaryAction.text.resolveReference(),
enabled = model.secondaryAction.enabled,
onClick = model.secondaryAction.onClick,
)
}
}
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_NotificationBottomSheet(
@PreviewParameter(MessageBottomSheetUMPreviewProvider::class) params: MessageBottomSheetUM,
) {
TangemThemePreview {
MessageBottomSheet(
config = TangemBottomSheetConfig(
content = params,
isShown = true,
onDismissRequest = {},
),
)
}
}
private class MessageBottomSheetUMPreviewProvider : PreviewParameterProvider<MessageBottomSheetUM> {
val balancesHiddenMessage = MessageBottomSheetUM(
iconResId = R.drawable.ic_eye_off_outline_24,
title = resourceReference(R.string.balance_hidden_title),
message = resourceReference(R.string.balance_hidden_description),
primaryAction = MessageBottomSheetUM.ActionUM(
text = resourceReference(R.string.balance_hidden_got_it_button),
onClick = {},
),
secondaryAction = MessageBottomSheetUM.ActionUM(
text = resourceReference(R.string.balance_hidden_do_not_show_button),
onClick = {},
),
)
override val values: Sequence<MessageBottomSheetUM>
get() = sequenceOf(
balancesHiddenMessage,
balancesHiddenMessage.copy(title = null),
balancesHiddenMessage.copy(iconResId = null),
balancesHiddenMessage.copy(primaryAction = null),
balancesHiddenMessage.copy(secondaryAction = null),
balancesHiddenMessage.copy(
primaryAction = null,
secondaryAction = null,
),
balancesHiddenMessage.copy(
title = null,
iconResId = null,
primaryAction = null,
secondaryAction = null,
),
)
}
// endregion Preview

View file

@ -0,0 +1,20 @@
package com.tangem.core.ui.components.bottomsheets.message
import androidx.annotation.DrawableRes
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
data class MessageBottomSheetUM(
@DrawableRes val iconResId: Int?,
val title: TextReference?,
val message: TextReference,
val primaryAction: ActionUM?,
val secondaryAction: ActionUM?,
) : TangemBottomSheetConfigContent {
data class ActionUM(
val text: TextReference,
val enabled: Boolean = true,
val onClick: () -> Unit,
)
}

View file

@ -20,16 +20,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
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.core.res.getStringSafe
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SecondaryButtonIconStart
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
@ -75,10 +73,9 @@ private fun TokenReceiveBottomSheetContent(content: TokenReceiveBottomSheetConfi
)
Buttons(
address = selectedAddress.value,
snackbarHostState = snackbarHostState,
onShareClick = content.onShareClick,
onCopyClick = content.onCopyClick,
onShareClick = { content.onShareClick(selectedAddress.value) },
onCopyClick = { content.onCopyClick(selectedAddress.value) },
)
}
}
@ -96,7 +93,7 @@ private fun Info(currency: String, network: String, showMemoDisclaimer: Boolean,
modifier = Modifier
.padding(horizontal = 18.dp)
.fillMaxWidth(),
text = stringResource(R.string.receive_bottom_sheet_no_memo_required_message),
text = stringResourceSafe(R.string.receive_bottom_sheet_no_memo_required_message),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
@ -191,7 +188,7 @@ private fun QrCodePage(content: TokenReceiveBottomSheetConfig, qrCodePainter: Pa
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text(
text = stringResource(
text = stringResourceSafe(
R.string.receive_bottom_sheet_warning_message,
getName(content = content, index = currentIndex),
content.symbol,
@ -229,14 +226,12 @@ private fun getName(content: TokenReceiveBottomSheetConfig, index: Int): String
@Composable
private fun Buttons(
address: String,
snackbarHostState: SnackbarHostState,
onShareClick: () -> Unit,
onCopyClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val clipboardManager = LocalClipboardManager.current
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val resources = context.resources
@ -247,17 +242,16 @@ private fun Buttons(
) {
SecondaryButtonIconStart(
modifier = Modifier.weight(1f),
text = stringResource(id = R.string.common_copy),
text = stringResourceSafe(id = R.string.common_copy),
iconResId = R.drawable.ic_copy_24,
onClick = {
onCopyClick()
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
clipboardManager.setText(AnnotatedString(address))
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = resources.getString(R.string.wallet_notification_address_copied),
message = resources.getStringSafe(R.string.wallet_notification_address_copied),
)
}
},
@ -265,13 +259,12 @@ private fun Buttons(
SecondaryButtonIconStart(
modifier = Modifier.weight(1f),
text = stringResource(id = R.string.common_share),
text = stringResourceSafe(id = R.string.common_share),
iconResId = R.drawable.ic_share_24,
onClick = {
onShareClick()
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
context.shareText(address)
onShareClick()
},
)
}
@ -286,7 +279,7 @@ private fun Preview_TokenReceiveBottomSheet(
) {
TangemThemePreview {
val config = TangemBottomSheetConfig(
isShow = true,
isShown = true,
content = params,
onDismissRequest = {},
)

View file

@ -9,6 +9,6 @@ data class TokenReceiveBottomSheetConfig(
val network: String,
val addresses: ImmutableList<AddressModel>,
val showMemoDisclaimer: Boolean,
val onCopyClick: () -> Unit,
val onShareClick: () -> Unit,
val onCopyClick: (String) -> Unit,
val onShareClick: (String) -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -4,7 +4,10 @@ import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@ -14,7 +17,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -24,8 +26,9 @@ import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.components.inputrow.inner.InputRowAsyncImage
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
/**
* [Input Row Best Rate](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-889&mode=dev)
@ -112,7 +115,7 @@ private fun InnerTitle(title: TextReference, titleExtra: TextReference, showTag:
)
if (showTag) {
Text(
text = stringResource(R.string.express_provider_best_rate),
text = stringResourceSafe(R.string.express_provider_best_rate),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
modifier = Modifier

View file

@ -18,9 +18,9 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
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.DEFAULT_ANIMATION_DURATION
@ -51,7 +51,7 @@ fun PasteButton(isPasteButtonVisible: Boolean, onClick: (String) -> Unit, modifi
modifier = modifier,
) {
Text(
text = stringResource(R.string.common_paste),
text = stringResourceSafe(R.string.common_paste),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary2,
modifier = Modifier
@ -86,7 +86,7 @@ fun CrossIcon(onClick: (String) -> Unit, modifier: Modifier = Modifier) {
Icon(
painter = painterResource(id = R.drawable.ic_close_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = stringResource(R.string.common_close),
contentDescription = stringResourceSafe(R.string.common_close),
modifier = modifier
.size(TangemTheme.dimens.size24)
.clickable(

View file

@ -10,7 +10,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -18,6 +17,7 @@ import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameter
import androidx.compose.ui.unit.Dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
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.BigDecimalFormatter
@ -61,7 +61,7 @@ private fun Title(currencyName: String, modifier: Modifier = Modifier) {
contentAlignment = Alignment.CenterStart,
) {
Text(
text = stringResource(id = R.string.wallet_marketplace_block_title, currencyName),
text = stringResourceSafe(id = R.string.wallet_marketplace_block_title, currencyName),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
)
@ -157,7 +157,7 @@ private fun LoadingContent() {
@Composable
private fun QuoteTimeStatus() {
Text(
text = stringResource(id = R.string.wallet_marketprice_block_update_time),
text = stringResourceSafe(id = R.string.wallet_marketprice_block_update_time),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
)

View file

@ -2,7 +2,9 @@
package com.tangem.core.ui.components.progressbar
import android.content.res.Configuration
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
@ -15,10 +17,12 @@ import androidx.compose.ui.layout.layout
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.offset
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlin.math.abs
/**
@ -123,4 +127,18 @@ private fun DrawScope.drawLinearIndicator(
}
private fun DrawScope.drawLinearIndicatorBackground(color: Color, strokeWidth: Float, strokeCap: StrokeCap) =
drawLinearIndicator(0f, 1f, color, strokeWidth, strokeCap)
drawLinearIndicator(0f, 1f, color, strokeWidth, strokeCap)
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun LinearProgressIndicator_Preview() {
TangemThemePreview {
LinearProgressIndicator(
progress = { 0.5f },
modifier = Modifier.fillMaxWidth(),
)
}
}
// endregion

View file

@ -14,7 +14,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -23,8 +22,9 @@ import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.utils.StringsSigns
@Composable
@ -77,7 +77,7 @@ fun SelectorRowItem(
contentDescription = null,
)
Text(
text = stringResource(titleRes),
text = stringResourceSafe(titleRes),
style = textStyle,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing8),

View file

@ -9,15 +9,18 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import com.tangem.core.ui.res.LocalSnackbarHostState
import com.tangem.core.ui.res.TangemThemePreview
/**
* Tangem snackbar host.
* Based on Material3 component. It's best way to show [TangemSnackbar].
* Based on Material3 component. It's the best way to show [TangemSnackbar].
*
* @param hostState snackbar host state
* @param modifier modifier
* @param actionOnNewLine flag that indicates if the action should be displayed on a new line (default: false)
* @param modifier Modifier to be applied to the layout.
* @param hostState SnackbarHostState that will be used to show snackbar.
* Optional parameter. By default, it uses [LocalSnackbarHostState.current].
* @param actionOnNewLine Flag that indicates if the action should be displayed on a new line.
* Optional parameter, `false` by default.
*
* @see <a href = https://www.figma.com/design/14ISV23YB1yVW1uNVwqrKv/Android?node-id=682-761&t=kDzSZDx0m0sk4iYz-4
* >Figma</a>
@ -25,7 +28,11 @@ import com.tangem.core.ui.res.TangemThemePreview
[REDACTED_AUTHOR]
*/
@Composable
fun TangemSnackbarHost(hostState: SnackbarHostState, modifier: Modifier = Modifier, actionOnNewLine: Boolean = false) {
fun TangemSnackbarHost(
modifier: Modifier = Modifier,
hostState: SnackbarHostState = LocalSnackbarHostState.current,
actionOnNewLine: Boolean = false,
) {
SnackbarHost(hostState = hostState, modifier = modifier) { data ->
TangemSnackbar(data = data, actionOnNewLine = actionOnNewLine)
}

View file

@ -0,0 +1,106 @@
package com.tangem.core.ui.components.stories
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.stories.inner.StoriesClickableArea
import com.tangem.core.ui.components.stories.inner.StoriesProgressBar
import com.tangem.core.ui.components.stories.inner.StoriesStepStateMachine
import com.tangem.core.ui.components.stories.model.StoriesContentConfig
import com.tangem.core.ui.components.stories.model.StoryConfig
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* Stories container component.
* Implements base logic for switching story pages and handling clicks
*
* @param config stories configuration
* @param modifier composable modifier
* @param isPauseStories pause stories progression externally
* @param currentStoryContent content of current displaying story
*/
@Composable
inline fun <reified T : StoryConfig> StoriesContainer(
config: StoriesContentConfig<T>,
modifier: Modifier = Modifier,
isPauseStories: Boolean = false,
crossinline currentStoryContent: @Composable BoxScope.(T, Boolean) -> Unit,
) {
var isPressed by remember { mutableStateOf(value = false) }
val storyState by remember(config) {
mutableStateOf(
StoriesStepStateMachine(
stories = config.stories,
isRepeatable = config.isRestartable,
),
)
}
val isPaused = isPressed || isPauseStories
Box(modifier = modifier) {
StoriesClickableArea(
onPress = { isPressed = it },
onPreviousStory = storyState::prevStory,
onNextStory = storyState::nextStory,
)
StoriesProgressBar(
steps = storyState.steps,
currentStep = storyState.currentIndex.intValue,
stepDuration = storyState.currentStory.duration,
paused = isPaused,
onStepFinish = storyState::nextStory,
)
currentStoryContent(storyState.currentStory, isPaused)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun StoriesContainer_Preview() {
TangemThemePreview {
StoriesContainer(
config = StoryContainerPreviewProviderData(),
modifier = Modifier.background(Color(0xFF010101)),
isPauseStories = false,
currentStoryContent = { pageConfig, _ ->
Text(
text = pageConfig.title,
color = TangemColorPalette.White,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center),
)
},
)
}
}
private data class StoryContainerPreviewProviderData(
override val stories: ImmutableList<StoryConfigPreviewProviderData> = persistentListOf(
StoryConfigPreviewProviderData(title = "Tangem"),
StoryConfigPreviewProviderData(title = "Cold"),
StoryConfigPreviewProviderData(title = "Wallet"),
),
override val isRestartable: Boolean = true,
) : StoriesContentConfig<StoryConfigPreviewProviderData>
private data class StoryConfigPreviewProviderData(
override val duration: Int = 3_000,
val title: String,
) : StoryConfig
// endregion

View file

@ -0,0 +1,56 @@
package com.tangem.core.ui.components.stories.inner
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
private const val STORY_HOLD_DELAY = 200
@Composable
fun StoriesClickableArea(onPress: (Boolean) -> Unit, onPreviousStory: () -> Unit, onNextStory: () -> Unit) {
Row(
modifier = Modifier.fillMaxSize(),
) {
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.pointerInput(Unit) {
detectTapGestures(
onPress = {
val pressStartTime = System.currentTimeMillis()
onPress(true)
this.tryAwaitRelease()
val pressEndTime = System.currentTimeMillis()
val totalPressTime = pressEndTime - pressStartTime
if (totalPressTime < STORY_HOLD_DELAY) onPreviousStory()
onPress(false)
},
)
},
)
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.pointerInput(Unit) {
detectTapGestures(
onPress = {
val pressStartTime = System.currentTimeMillis()
onPress(true)
this.tryAwaitRelease()
val pressEndTime = System.currentTimeMillis()
val totalPressTime = pressEndTime - pressStartTime
if (totalPressTime < STORY_HOLD_DELAY) onNextStory()
onPress(false)
},
)
},
)
}
}

View file

@ -0,0 +1,127 @@
package com.tangem.core.ui.components.stories.inner
import android.provider.Settings
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import kotlinx.coroutines.delay
private const val STORIES_ANIMATION_SPEED_ZERO_DURATION = 3000L
const val STORY_DURATION = 8_000
private suspend fun animationProgress(
paused: Boolean,
animatorSpeed: Float,
stepDuration: Int,
progress: Animatable<Float, AnimationVector1D>,
onStepFinish: () -> Unit,
) {
if (paused) {
progress.stop()
} else {
if (animatorSpeed == 0f) {
progress.snapTo(1f)
delay(STORIES_ANIMATION_SPEED_ZERO_DURATION)
} else {
progress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = (stepDuration * (1f - progress.value)).toInt(),
easing = LinearEasing,
),
)
progress.snapTo(0f)
}
onStepFinish()
}
}
@Composable
fun StoriesProgressBar(
steps: Int,
currentStep: Int,
paused: Boolean = false,
stepDuration: Int = STORY_DURATION,
onStepFinish: () -> Unit = {},
) {
val progress = remember(currentStep) { Animatable(initialValue = 0f) }
val context = LocalContext.current
val animatorSpeed = Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
)
LaunchedEffect(paused, currentStep, animatorSpeed) {
animationProgress(
paused = paused,
animatorSpeed = animatorSpeed,
stepDuration = stepDuration,
progress = progress,
onStepFinish = onStepFinish,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(
start = 16.dp,
end = 16.dp,
top = 16.dp,
),
) {
for (index in 0..steps) {
Row(
modifier = Modifier
.height(2.dp)
.weight(1f)
.clip(RoundedCornerShape(2.dp))
.background(TangemColorPalette.White.copy(alpha = .2f)),
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(2.dp))
.background(TangemColorPalette.White)
.fillMaxHeight()
.let {
when (index) {
currentStep -> it.fillMaxWidth(progress.value)
in 0..currentStep -> it.fillMaxWidth(fraction = 1f)
else -> it
}
},
)
}
SpacerW4()
}
}
}
@Preview
@Composable
private fun StoriesProgressBarPreview() {
Box(
modifier = Modifier
.wrapContentSize()
.background(TangemColorPalette.Black)
.padding(vertical = TangemTheme.dimens.spacing16),
) {
StoriesProgressBar(steps = 5, currentStep = 3, paused = false)
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.core.ui.components.stories.inner
import androidx.compose.runtime.IntState
import androidx.compose.runtime.asIntState
import androidx.compose.runtime.mutableIntStateOf
import kotlinx.collections.immutable.ImmutableList
class StoriesStepStateMachine<T>(
private val stories: ImmutableList<T>,
private val isRepeatable: Boolean,
) {
private var _currentIndex = mutableIntStateOf(FIRST_INDEX)
val steps = stories.lastIndex
val currentIndex: IntState
get() = _currentIndex.asIntState()
val currentStory
get() = stories[currentIndex.intValue.coerceIn(FIRST_INDEX, steps)]
fun nextStory(): Boolean {
_currentIndex.intValue = if (currentIndex.intValue == steps && isRepeatable) {
FIRST_INDEX
} else {
currentIndex.intValue.inc().coerceAtMost(stories.size)
}
return currentIndex.intValue > steps
}
fun prevStory() {
_currentIndex.intValue = currentIndex.intValue.dec().coerceAtLeast(FIRST_INDEX)
}
private companion object {
const val FIRST_INDEX = 0
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.core.ui.components.stories.model
import kotlinx.collections.immutable.ImmutableList
/**
* Config for stories component
*
* @property stories configuration list
* @property isRestartable indicates than stories progressions starts
*/
interface StoriesContentConfig<T : StoryConfig> {
val stories: ImmutableList<T>
val isRestartable: Boolean
}
interface StoryConfig {
val duration: Int
}

View file

@ -10,10 +10,10 @@ 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.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.tangem.core.ui.R
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import org.burnoutcrew.reorderable.ReorderableLazyListState
import org.burnoutcrew.reorderable.detectReorder
@ -67,7 +67,7 @@ private fun DraggableImage(reorderableTokenListState: ReorderableLazyListState?)
@Composable
private fun NonFiatContentText(@StringRes text: Int) {
Text(
text = stringResource(id = text),
text = stringResourceSafe(id = text),
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,

View file

@ -6,13 +6,13 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.audits.AuditLabel
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.components.token.state.TokenItemState.Subtitle2State as TokenCryptoAmountState
@ -33,7 +33,7 @@ internal fun TokenCryptoAmount(
AuditLabel(state = state.auditLabelUM, modifier = modifier)
}
is TokenCryptoAmountState.Unreachable -> {
CryptoAmountText(amount = stringResource(id = R.string.common_unreachable), modifier = modifier)
CryptoAmountText(amount = stringResourceSafe(id = R.string.common_unreachable), modifier = modifier)
}
is TokenCryptoAmountState.Loading -> {
RectangleShimmer(modifier = modifier.placeholderSize(), radius = TangemTheme.dimens.radius4)

View file

@ -8,7 +8,6 @@ import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.components.tokenlist.internal.GroupTitleItem
import com.tangem.core.ui.components.tokenlist.internal.NetworkTitleItem
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
@ -25,9 +24,6 @@ import com.tangem.core.ui.res.TangemTheme
@Composable
fun TokenListItem(state: TokensListItemUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
when (state) {
is TokensListItemUM.NetworkGroupTitle -> {
NetworkTitleItem(networkName = state.name.resolveReference(), modifier = modifier)
}
is TokensListItemUM.GroupTitle -> {
GroupTitleItem(state, modifier)
}

View file

@ -1,12 +1,32 @@
package com.tangem.core.ui.components.tokenlist.internal
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.R
import com.tangem.core.ui.components.rows.NetworkTitle
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
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 org.burnoutcrew.reorderable.ReorderableLazyListState
import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
/**
* Group title item
@ -18,14 +38,90 @@ import com.tangem.core.ui.res.TangemTheme
*/
@Composable
fun GroupTitleItem(state: TokensListItemUM.GroupTitle, modifier: Modifier = Modifier) {
BaseGroupTitleItem(text = state.text, modifier = modifier)
}
/**
* Draggable network group item
*
* @param state state
* @param reorderableTokenListState reorderable token list state
* @param modifier modifier
*/
@Composable
fun DraggableGroupTitleItem(
state: TokensListItemUM.GroupTitle,
reorderableTokenListState: ReorderableLazyListState,
modifier: Modifier = Modifier,
) {
BaseGroupTitleItem(
text = state.text,
modifier = modifier,
action = { DraggableIcon(reorderableTokenListState = reorderableTokenListState) },
)
}
@Composable
private fun BaseGroupTitleItem(
text: TextReference,
modifier: Modifier = Modifier,
action: (@Composable BoxScope.() -> Unit)? = null,
) {
NetworkTitle(
title = {
Text(
text = state.text.resolveReference(),
text = text.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
},
modifier = modifier,
action = action,
)
}
}
@Composable
private fun DraggableIcon(reorderableTokenListState: ReorderableLazyListState) {
Box(
modifier = Modifier.detectReorder(reorderableTokenListState),
contentAlignment = Alignment.Center,
) {
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(id = R.drawable.ic_group_drop_24),
),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun NetworkTitleItemPreview(@PreviewParameter(GroupTitleItemProvider::class) isDraggable: Boolean) {
TangemThemePreview {
val state = TokensListItemUM.GroupTitle(
id = 1,
text = resourceReference(
id = R.string.wallet_network_group_title,
formatArgs = wrappedList(resourceReference(id = R.string.main_tokens)),
),
)
if (isDraggable) {
DraggableGroupTitleItem(
state = state,
reorderableTokenListState = rememberReorderableLazyListState(onMove = { _, _ -> }),
modifier = Modifier.background(color = TangemTheme.colors.background.primary),
)
} else {
GroupTitleItem(
state = state,
modifier = Modifier.background(color = TangemTheme.colors.background.primary),
)
}
}
}
private object GroupTitleItemProvider : CollectionPreviewParameterProvider<Boolean>(collection = listOf(true, false))

View file

@ -1,113 +0,0 @@
package com.tangem.core.ui.components.tokenlist.internal
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.R
import com.tangem.core.ui.components.rows.NetworkTitle
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import org.burnoutcrew.reorderable.ReorderableLazyListState
import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
/**
* Network title item
*
* @param networkName network name
* @param modifier modifier
*/
@Composable
internal fun NetworkTitleItem(networkName: String, modifier: Modifier = Modifier) {
BaseNetworkTitleItem(networkName = networkName, modifier = modifier)
}
/**
* Draggable network title item
*
* @param networkName network name
* @param reorderableTokenListState reorderable token list state
* @param modifier modifier
*/
@Composable
fun DraggableNetworkTitleItem(
networkName: String,
reorderableTokenListState: ReorderableLazyListState,
modifier: Modifier = Modifier,
) {
BaseNetworkTitleItem(
networkName = networkName,
modifier = modifier,
action = { DraggableIcon(reorderableTokenListState = reorderableTokenListState) },
)
}
@Composable
private fun BaseNetworkTitleItem(
networkName: String,
modifier: Modifier = Modifier,
action: (@Composable BoxScope.() -> Unit)? = null,
) {
NetworkTitle(
title = {
Text(
text = stringResource(id = R.string.wallet_network_group_title, networkName),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
},
modifier = modifier,
action = action,
)
}
@Composable
private fun DraggableIcon(reorderableTokenListState: ReorderableLazyListState) {
Box(
modifier = Modifier.detectReorder(reorderableTokenListState),
contentAlignment = Alignment.Center,
) {
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(id = R.drawable.ic_group_drop_24),
),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun NetworkTitleItemPreview(@PreviewParameter(NetworkTitleItemProvider::class) isDraggable: Boolean) {
TangemThemePreview {
if (isDraggable) {
DraggableNetworkTitleItem(
networkName = "Ethereum",
reorderableTokenListState = rememberReorderableLazyListState(onMove = { _, _ -> }),
modifier = Modifier.background(color = TangemTheme.colors.background.primary),
)
} else {
NetworkTitleItem(
networkName = "Ethereum",
modifier = Modifier.background(color = TangemTheme.colors.background.primary),
)
}
}
}
private object NetworkTitleItemProvider : CollectionPreviewParameterProvider<Boolean>(collection = listOf(true, false))

View file

@ -23,15 +23,6 @@ sealed interface TokensListItemUM {
val searchBarUM: SearchBarUM,
) : TokensListItemUM
/**
* Network group title
*
* @property id id
* @property name network group name
*/
@Deprecated("Use GroupTitle instead") // TODO: [REDACTED_JIRA]
data class NetworkGroupTitle(override val id: Int, val name: TextReference) : TokensListItemUM
/**
* Group title
*

View file

@ -14,7 +14,6 @@ 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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@ -234,7 +233,7 @@ private fun Subtitle(state: TransactionState, modifier: Modifier = Modifier) {
is TransactionState.Content -> {
Text(
text = when (state.status) {
is Status.Failed -> stringResource(id = R.string.common_transaction_failed)
is Status.Failed -> stringResourceSafe(id = R.string.common_transaction_failed)
else -> state.subtitle.resolveReference()
},
modifier = modifier,

View file

@ -10,11 +10,11 @@ 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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
/**
* Transactions block title
@ -35,7 +35,7 @@ internal fun TxHistoryTitle(onExploreClick: () -> Unit, modifier: Modifier = Mod
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(id = R.string.common_transactions),
text = stringResourceSafe(id = R.string.common_transactions),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
)
@ -52,7 +52,7 @@ internal fun TxHistoryTitle(onExploreClick: () -> Unit, modifier: Modifier = Mod
contentDescription = null,
)
Text(
text = stringResource(id = R.string.common_explorer),
text = stringResourceSafe(id = R.string.common_explorer),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
)

View file

@ -84,6 +84,7 @@ fun getActiveIconRes(blockchainId: String): Int {
"canxium" -> R.drawable.img_canxium_22
"chiliz", "chiliz/test" -> R.drawable.img_chiliz_22
"clore-ai" -> R.drawable.img_clore_22
"fact0rn" -> R.drawable.img_fact0rn_22
else -> R.drawable.ic_alert_24
}
}
@ -166,6 +167,7 @@ fun getActiveIconResByCoinId(coinId: String): Int {
"canxium" -> R.drawable.img_canxium_22
"chiliz", "chiliz/test" -> R.drawable.img_chiliz_22
"clore-ai" -> R.drawable.img_clore_22
"fact0rn" -> R.drawable.img_fact0rn_22
else -> R.drawable.ic_alert_24
}
}
@ -251,6 +253,7 @@ fun getGreyedOutIconRes(blockchainId: String): Int {
"canxium" -> R.drawable.ic_canxium_22
"chiliz", "chiliz/test" -> R.drawable.ic_chiliz_22
"clore-ai" -> R.drawable.ic_clore_22
"fact0rn" -> R.drawable.ic_fact0rn_22
else -> R.drawable.ic_alert_24
}
}

View file

@ -1,15 +0,0 @@
package com.tangem.core.ui.extensions
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
fun Context.shareText(text: String) {
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, text)
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, null)
ContextCompat.startActivity(this, shareIntent, null)
}

View file

@ -2,12 +2,56 @@ package com.tangem.core.ui.extensions
import android.graphics.Bitmap
import android.graphics.Color
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalContext
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.tangem.core.res.getPluralStringSafe
import com.tangem.core.res.getStringSafe
import java.util.Hashtable
/**
* Get a string resource safely or the resource name if an exception is thrown
*
* @param id resource id
* @param formatArgs format args
*/
@Composable
@ReadOnlyComposable
fun stringResourceSafe(@StringRes id: Int, vararg formatArgs: Any): String {
val resources = LocalContext.current.resources
return if (formatArgs.isEmpty()) {
resources.getStringSafe(id)
} else {
resources.getStringSafe(id, *formatArgs)
}
}
/**
* Get a plural string resource safely or the resource name if an exception is thrown
*
* @param id resource id
* @param count count
* @param formatArgs format args
*/
@Composable
@ReadOnlyComposable
fun pluralStringResourceSafe(@PluralsRes id: Int, count: Int, vararg formatArgs: Any): String {
val resources = LocalContext.current.resources
return if (formatArgs.isEmpty()) {
resources.getPluralStringSafe(id, count)
} else {
resources.getPluralStringSafe(id, count, *formatArgs)
}
}
@Suppress("MagicNumber")
fun String.toQrCode(sizePx: Int = 256, paddingPx: Int = 0): Bitmap {
val hintMap = Hashtable<EncodeHintType, Any>()

View file

@ -6,12 +6,11 @@ import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.AnnotatedString.Builder
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.decapitalize
import com.tangem.core.res.getPluralStringSafe
import com.tangem.core.res.getStringSafe
import org.intellij.markdown.MarkdownElementTypes
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@ -172,7 +171,7 @@ fun TextReference.resolveReference(): String {
.map { if (it is TextReference) it.resolveReference() else it }
.toTypedArray()
val resolvedReference = stringResource(id = id, *args)
val resolvedReference = stringResourceSafe(id = id, *args)
if (decapitalize) {
resolvedReference.replaceFirstChar { char -> char.lowercase() }
@ -180,7 +179,7 @@ fun TextReference.resolveReference(): String {
resolvedReference
}
}
is TextReference.PluralRes -> pluralStringResource(id, count, *formatArgs.toTypedArray())
is TextReference.PluralRes -> pluralStringResourceSafe(id, count, *formatArgs.toTypedArray())
is TextReference.Str -> value
is TextReference.Annotated -> value.text
is TextReference.Combined -> {
@ -201,9 +200,9 @@ fun TextReference.resolveReference(resources: Resources): String {
.map { if (it is TextReference) it.resolveReference(resources) else it }
.toTypedArray()
resources.getString(id, *args)
resources.getStringSafe(id, *args)
}
is TextReference.PluralRes -> resources.getQuantityString(id, count, *formatArgs.toTypedArray())
is TextReference.PluralRes -> resources.getPluralStringSafe(id, count, *formatArgs.toTypedArray())
is TextReference.Str -> value
is TextReference.Annotated -> value.text
is TextReference.Combined -> {
@ -225,10 +224,10 @@ fun TextReference.resolveAnnotatedReference(): AnnotatedString {
.map { if (it is TextReference) it.resolveReference() else it }
.toTypedArray()
formatAnnotated(stringResource(id = id, *args))
formatAnnotated(stringResourceSafe(id = id, *args))
}
is TextReference.PluralRes -> formatAnnotated(
pluralStringResource(id, count, *formatArgs.toTypedArray()),
pluralStringResourceSafe(id, count, *formatArgs.toTypedArray()),
)
is TextReference.Str -> formatAnnotated(value)
is TextReference.Annotated -> value

View file

@ -1,11 +1,14 @@
package com.tangem.core.ui.message
import androidx.compose.material3.SnackbarDuration
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.ui.UiMessage
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage.Duration
/**
* Event that is used to show a message in the UI.
@ -19,16 +22,40 @@ sealed interface EventMessage : UiMessage
* Shows a snackbar.
*
* @param message The message to show.
* @param duration The duration of the snackbar.
* @param actionLabel The label of the action button.
* @param action The action to perform when the action button is clicked.
* @param duration The duration of the snackbar. Optional, [Duration.Short] by default.
* @param onDismissRequest The action to perform when the snackbar is dismissed. Optional, empty lambda by default.
* @param actionLabel The label of the action button. Optional, `null` by default.
* @param action The action to perform when the action button is clicked. Optional, `null` by default.
* */
data class SnackbarMessage(
val message: TextReference,
val duration: SnackbarDuration = SnackbarDuration.Short,
val duration: Duration = Duration.Short,
val onDismissRequest: () -> Unit = {},
val actionLabel: TextReference? = null,
val action: (() -> Unit)? = null,
) : EventMessage
) : EventMessage {
/**
* The duration of the snackbar.
* */
enum class Duration {
/**
* Shows the snackbar for a short period of time.
* */
Short,
/**
* Shows the snackbar for a long period of time.
* */
Long,
/**
* Shows the snackbar indefinitely, until dismissed or action performed.
* */
Indefinite,
}
}
/**
* Shows a [content] in the UI.
@ -44,4 +71,170 @@ data class ContentMessage(val content: Content) : EventMessage {
@Composable
operator fun invoke(onDismiss: () -> Unit)
}
}
/**
* Shows an alert dialog.
*
* @param message The message to show.
* @param title The title of the dialog. Optional, `null` by default.
* @param firstAction The first dialog action to perform.
* @param secondAction The second dialog action to perform. Optional, `null` by default.
* Performing the second action always dismisses the dialog.
* @param isDismissable If `false` then dialog can not be dismissed by back button click or by outside click.
* @param dismissOnFirstAction If `true` then dialog is dismissed when first action is performed,
* ignoring lambda passed to [EventMessageAction.onClick].
* `true` by default.
* @param onDismissRequest The action to perform when the dialog is dismissed.
* */
data class DialogMessage(
val message: TextReference,
val title: TextReference? = null,
val firstAction: EventMessageAction,
val secondAction: EventMessageAction? = null,
val isDismissable: Boolean = true,
val dismissOnFirstAction: Boolean = true,
val onDismissRequest: () -> Unit = {},
) : EventMessage {
companion object {
/**
* Builder for [DialogMessage].
*
* @param message The message to show.
* @param title The title of the dialog. Optional, `null` by default.
* @param isDismissable If `false` then dialog can not be dismissed by back button click or by outside click.
* `true` by default.
* @param dismissOnFirstAction If `true` then dialog is dismissed when first action is performed,
* ignoring lambda passed to [EventMessageAction.onClick].
* `true` by default.
* @param onDismissRequest The action to perform when the dialog is dismissed.
* @param firstActionBuilder The builder for the first action.
* By default, it builds an action with title "OK".
* @param secondActionBuilder The builder for the second action. Optional, `null` by default.
* Performing the second action always dismisses the dialog.
*
* @return [DialogMessage] with the specified parameters.
* */
operator fun invoke(
message: TextReference,
title: TextReference? = null,
isDismissable: Boolean = true,
dismissOnFirstAction: Boolean = true,
onDismissRequest: () -> Unit = {},
firstActionBuilder: EventMessageAction.BuilderScope.() -> EventMessageAction = { okAction() },
secondActionBuilder: (EventMessageAction.BuilderScope.() -> EventMessageAction)? = null,
): DialogMessage {
val buttonsScope = EventMessageAction.BuilderScope(onDismissRequest)
return DialogMessage(
message = message,
title = title,
isDismissable = isDismissable,
dismissOnFirstAction = dismissOnFirstAction,
onDismissRequest = onDismissRequest,
firstAction = firstActionBuilder(buttonsScope),
secondAction = secondActionBuilder?.invoke(buttonsScope),
)
}
}
}
/**
* Shows a bottom sheet.
*
* @param iconResId The icon to show in the bottom sheet.
* Optional, `null` by default.
* @param title The title of the bottom sheet. Optional, `null` by default.
* @param message The message to show in the bottom sheet.
* @param firstAction The first action to perform. Optional, `null` by default.
* @param secondAction The second action to perform. Optional, `null` by default.
* @param onDismissRequest The action to perform when the bottom sheet is dismissed.
* */
data class BottomSheetMessage(
@DrawableRes val iconResId: Int? = null,
val title: TextReference? = null,
val message: TextReference,
val firstAction: EventMessageAction? = null,
val secondAction: EventMessageAction? = null,
val onDismissRequest: () -> Unit = {},
) : EventMessage {
companion object {
/**
* Builder for [BottomSheetMessage].
*
* @param iconResId The icon to show in the bottom sheet. Optional, `null` by default.
* @param title The title of the bottom sheet. Optional, `null` by default.
* @param message The message to show in the bottom sheet.
* @param onDismissRequest The action to perform when the bottom sheet is dismissed.
* @param firstActionBuilder The builder for the first action. Optional, `null` by default.
* @param secondActionBuilder The builder for the second action. Optional, `null` by default.
* */
operator fun invoke(
@DrawableRes iconResId: Int?,
title: TextReference?,
message: TextReference,
onDismissRequest: () -> Unit = {},
firstActionBuilder: (EventMessageAction.BuilderScope.() -> EventMessageAction)? = null,
secondActionBuilder: (EventMessageAction.BuilderScope.() -> EventMessageAction)? = null,
): BottomSheetMessage {
val buttonsScope = EventMessageAction.BuilderScope(onDismissRequest)
return BottomSheetMessage(
iconResId = iconResId,
title = title,
message = message,
onDismissRequest = onDismissRequest,
firstAction = firstActionBuilder?.invoke(buttonsScope),
secondAction = secondActionBuilder?.invoke(buttonsScope),
)
}
}
}
/**
* Represents an action button in the dialog.
*
* @param title The title of the action.
* @param warning If `true` then the action is highlighted as a warning.
* @param enabled If `false` then the action is disabled.
* @param onClick The action to perform when the button is clicked.
* */
data class EventMessageAction(
val title: TextReference,
val warning: Boolean = false,
val enabled: Boolean = true,
val onClick: () -> Unit,
) {
/**
* Scope for building buttons for the message.
*
* @param onDismissRequest The action to perform when the dialog is dismissed.
* */
class BuilderScope internal constructor(val onDismissRequest: () -> Unit) {
/**
* Builds an action with title "OK".
*
* @param onClick The action to perform when the button is clicked. By default, it dismisses the message.
* */
fun okAction(onClick: () -> Unit = onDismissRequest) = EventMessageAction(
title = resourceReference(id = R.string.common_ok),
onClick = onClick,
)
/**
* Builds an action with title "Cancel".
*
* @param onClick The action to perform when the button is clicked. By default, it dismisses the message.
* */
fun cancelAction(onClick: () -> Unit = onDismissRequest) = EventMessageAction(
title = resourceReference(id = R.string.common_cancel),
onClick = onClick,
)
}
}

View file

@ -1,26 +1,49 @@
package com.tangem.core.ui.message
import android.content.Context
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet
import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.LocalEventMessageHandler
import com.tangem.core.ui.res.LocalSnackbarHostState
@Composable
fun EventMessageEffect(messageHandler: EventMessageHandler, snackbarHostState: SnackbarHostState) {
fun EventMessageEffect(
messageHandler: EventMessageHandler = LocalEventMessageHandler.current,
snackbarHostState: SnackbarHostState = LocalSnackbarHostState.current,
onShowSnackbar: suspend (SnackbarMessage, Context) -> Unit = { message, context ->
showSnackbar(snackbarHostState, message, context)
},
) {
val messageEvent by messageHandler.collectAsState()
val context = LocalContext.current
var contentMessage: ContentMessage? by remember { mutableStateOf(value = null) }
var dialogMessage: DialogMessage? by remember { mutableStateOf(value = null) }
var bottomSheetMessage: BottomSheetMessage? by remember { mutableStateOf(value = null) }
EventEffect(event = messageEvent) { message ->
when (message) {
is SnackbarMessage -> {
onShowSnackbar(message, context)
}
is ContentMessage -> {
contentMessage = message
}
is SnackbarMessage -> {
showSnackbar(snackbarHostState, message, context)
is DialogMessage -> {
dialogMessage = message
}
is BottomSheetMessage -> {
bottomSheetMessage = message
}
}
}
@ -28,16 +51,110 @@ fun EventMessageEffect(messageHandler: EventMessageHandler, snackbarHostState: S
contentMessage?.content?.invoke(
onDismiss = { contentMessage = null },
)
dialogMessage?.let { message ->
MessageDialog(
message = message,
onDismissRequest = {
dialogMessage = null
message.onDismissRequest()
},
)
}
bottomSheetMessage?.let { message ->
MessageBottomSheet(
message = message,
onDismissRequest = {
bottomSheetMessage = null
message.onDismissRequest()
},
)
}
}
@Composable
private fun MessageBottomSheet(message: BottomSheetMessage, onDismissRequest: () -> Unit) {
val config = TangemBottomSheetConfig(
isShown = true,
content = MessageBottomSheetUM(
iconResId = message.iconResId,
title = message.title,
message = message.message,
primaryAction = message.firstAction?.let { action ->
MessageBottomSheetUM.ActionUM(
text = action.title,
enabled = action.enabled,
onClick = {
action.onClick()
onDismissRequest()
},
)
},
secondaryAction = message.secondAction?.let { action ->
MessageBottomSheetUM.ActionUM(
text = action.title,
enabled = action.enabled,
onClick = {
action.onClick()
onDismissRequest()
},
)
},
),
onDismissRequest = onDismissRequest,
)
MessageBottomSheet(config)
}
@Composable
private fun MessageDialog(message: DialogMessage, onDismissRequest: () -> Unit) {
BasicDialog(
message = message.message.resolveReference(),
title = message.title?.resolveReference(),
confirmButton = message.firstAction.let { action ->
DialogButtonUM(
title = action.title.resolveReference(),
warning = action.warning,
enabled = action.enabled,
onClick = {
action.onClick()
if (message.dismissOnFirstAction) {
onDismissRequest()
}
},
)
},
dismissButton = message.secondAction?.let { action ->
DialogButtonUM(
title = action.title.resolveReference(),
warning = action.warning,
enabled = action.enabled,
onClick = {
action.onClick()
onDismissRequest()
},
)
},
onDismissDialog = onDismissRequest,
)
}
private suspend fun showSnackbar(snackbarHostState: SnackbarHostState, message: SnackbarMessage, context: Context) {
val result = snackbarHostState.showSnackbar(
message = message.message.resolveReference(context.resources),
actionLabel = message.actionLabel?.resolveReference(context.resources),
duration = message.duration,
duration = when (message.duration) {
SnackbarMessage.Duration.Short -> SnackbarDuration.Short
SnackbarMessage.Duration.Long -> SnackbarDuration.Long
SnackbarMessage.Duration.Indefinite -> SnackbarDuration.Indefinite
},
)
if (result == SnackbarResult.ActionPerformed) {
message.action?.invoke()
when (result) {
SnackbarResult.Dismissed -> message.onDismissRequest()
SnackbarResult.ActionPerformed -> message.action?.invoke()
}
}

View file

@ -1,5 +1,7 @@
package com.tangem.core.ui.res
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material.Colors
@ -10,13 +12,42 @@ import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.components.TangemShimmer
import com.tangem.core.ui.haptic.DefaultHapticManager
import com.tangem.core.ui.haptic.HapticManager
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.core.ui.message.EventMessageHandler
import com.tangem.core.ui.windowsize.WindowSize
import com.tangem.core.ui.windowsize.rememberWindowSize
import com.tangem.domain.apptheme.model.AppThemeMode
import com.valentinilk.shimmer.Shimmer
@Composable
fun TangemTheme(
activity: Activity,
uiDependencies: UiDependencies,
typography: TangemTypography = TangemTheme.typography,
dimens: TangemDimens = TangemTheme.dimens,
overrideSystemBarColors: Boolean = true,
content: @Composable () -> Unit,
) {
val appThemeMode by uiDependencies.appThemeModeHolder.appThemeMode
val windowSize = rememberWindowSize(activity = activity)
TangemTheme(
isDark = shouldUseDarkTheme(appThemeMode),
windowSize = windowSize,
vibratorHapticManager = uiDependencies.vibratorHapticManager,
snackbarHostState = uiDependencies.globalSnackbarHostState,
eventMessageHandler = uiDependencies.eventMessageHandler,
overrideSystemBarColors = overrideSystemBarColors,
typography = typography,
dimens = dimens,
content = content,
)
}
@Composable
fun TangemTheme(
isDark: Boolean = false,
@ -24,6 +55,7 @@ fun TangemTheme(
typography: TangemTypography = TangemTheme.typography,
dimens: TangemDimens = TangemTheme.dimens,
vibratorHapticManager: VibratorHapticManager? = null,
eventMessageHandler: EventMessageHandler = remember { EventMessageHandler() },
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
overrideSystemBarColors: Boolean = true,
content: @Composable () -> Unit,
@ -64,6 +96,7 @@ fun TangemTheme(
LocalIsInDarkTheme provides isDark,
LocalHapticManager provides hapticManager,
LocalSnackbarHostState provides snackbarHostState,
LocalEventMessageHandler provides eventMessageHandler,
LocalWindowSize provides windowSize,
) {
CompositionLocalProvider(
@ -256,6 +289,10 @@ val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
val LocalEventMessageHandler = staticCompositionLocalOf<EventMessageHandler> {
error("No EventMessageHandler provided")
}
val LocalWindowSize = staticCompositionLocalOf<WindowSize> {
error("No WindowSize provided")
}
@ -266,4 +303,20 @@ val LocalTangemShimmer = staticCompositionLocalOf<Shimmer> {
val LocalMainBottomSheetColor = staticCompositionLocalOf<MutableState<Color>> {
error("No MainBottomSheetColor provided")
}
/**
* Determines whether the dark theme should be used based on the given [AppThemeMode].
*
* @param appThemeMode The application theme mode.
* @return `true` if the dark theme should be used, `false` otherwise.
*/
@Composable
@ReadOnlyComposable
private fun shouldUseDarkTheme(appThemeMode: AppThemeMode): Boolean {
return when (appThemeMode) {
AppThemeMode.FORCE_DARK -> true
AppThemeMode.FORCE_LIGHT -> false
AppThemeMode.FOLLOW_SYSTEM -> isSystemInDarkTheme()
}
}

View file

@ -2,17 +2,13 @@ package com.tangem.core.ui.screen
import android.app.Activity
import android.content.Context
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.windowsize.rememberWindowSize
import com.tangem.domain.apptheme.model.AppThemeMode
/**
* Interface representing a Compose screen with common theming and content composition properties.
@ -60,34 +56,13 @@ internal fun ComposeScreen.createComposeView(
): ComposeView {
return ComposeView(context).apply {
setContent {
val appThemeMode by uiDependencies.appThemeModeHolder.appThemeMode
val windowSize = rememberWindowSize(activity = activity)
TangemTheme(
isDark = shouldUseDarkTheme(appThemeMode),
windowSize = windowSize,
vibratorHapticManager = uiDependencies.vibratorHapticManager,
snackbarHostState = uiDependencies.globalSnackbarHostState,
activity = activity,
uiDependencies = uiDependencies,
overrideSystemBarColors = overrideSystemBarColors,
) {
ScreenContent(modifier = screenModifier)
}
}
}
}
/**
* Determines whether the dark theme should be used based on the given [AppThemeMode].
*
* @param appThemeMode The application theme mode.
* @return `true` if the dark theme should be used, `false` otherwise.
*/
@Composable
@ReadOnlyComposable
private fun shouldUseDarkTheme(appThemeMode: AppThemeMode): Boolean {
return when (appThemeMode) {
AppThemeMode.FORCE_DARK -> true
AppThemeMode.FORCE_LIGHT -> false
AppThemeMode.FOLLOW_SYSTEM -> isSystemInDarkTheme()
}
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M11.573,4L11.591,9.381V14.813H11.146H10.701L10.726,9.412L10.752,4.01L11.174,4.003L11.573,4ZM9.691,9.412L9.666,4.01H9.271H8.876L8.85,9.412L8.825,14.813H9.271H9.717L9.691,9.412ZM3.889,15.807V14.411L4.308,14.633C5.114,15.059 6.309,14.861 6.919,14.2C7.477,13.597 7.7,12.428 7.43,11.52C7.174,10.66 6.32,10.034 5.403,10.034C4.852,10.034 4.325,10.182 4.062,10.412C3.904,10.549 3.889,10.543 3.889,10.348C3.889,10.16 3.834,10.134 3.444,10.134H3V13.602C3,15.509 3.03,17.1 3.066,17.136C3.102,17.173 3.302,17.203 3.51,17.203H3.889V15.807ZM13.985,11.801L14.011,8.789L16.035,11.799L18.059,14.807L18.53,14.81L19,14.813L18.974,11.104L18.944,7.331L18.539,7.342L18.112,7.334L18.086,10.383L18.059,13.431L16.023,10.413L13.987,7.396H13.555H13.122L13.096,11.104L13.07,14.813H13.514H13.958L13.985,11.801ZM4.556,13.86C3.936,13.521 3.674,12.232 4.086,11.551C4.631,10.65 5.943,10.598 6.5,11.455C6.914,12.092 6.768,13.21 6.205,13.709C5.909,13.97 5.78,14.016 5.342,14.013C5.059,14.01 4.705,13.942 4.556,13.86Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View file

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<group>
<clip-path
android:pathData="M11,0L11,0A11,11 0,0 1,22 11L22,11A11,11 0,0 1,11 22L11,22A11,11 0,0 1,0 11L0,11A11,11 0,0 1,11 0z"/>
<path
android:pathData="M11,0L11,0A11,11 0,0 1,22 11L22,11A11,11 0,0 1,11 22L11,22A11,11 0,0 1,0 11L0,11A11,11 0,0 1,11 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M0,0h22v22h-22z"
android:fillColor="#F8FBF8"/>
<path
android:pathData="M11.573,4L11.591,9.381V14.813H11.146H10.701L10.726,9.412L10.752,4.01L11.174,4.003L11.573,4ZM9.691,9.412L9.666,4.01H9.271H8.876L8.85,9.412L8.825,14.813H9.271H9.717L9.691,9.412ZM3.889,15.807V14.411L4.308,14.633C5.114,15.059 6.309,14.861 6.919,14.2C7.477,13.597 7.7,12.428 7.43,11.52C7.174,10.66 6.32,10.034 5.403,10.034C4.852,10.034 4.325,10.182 4.062,10.412C3.904,10.549 3.889,10.543 3.889,10.348C3.889,10.16 3.834,10.134 3.444,10.134H3V13.602C3,15.509 3.03,17.1 3.066,17.136C3.102,17.173 3.302,17.203 3.51,17.203H3.889V15.807ZM13.985,11.801L14.011,8.789L16.035,11.799L18.059,14.807L18.53,14.81L19,14.813L18.974,11.104L18.944,7.331L18.539,7.342L18.112,7.334L18.086,10.383L18.059,13.431L16.023,10.413L13.987,7.396H13.555H13.122L13.096,11.104L13.07,14.813H13.514H13.958L13.985,11.801ZM4.556,13.86C3.936,13.521 3.674,12.232 4.086,11.551C4.631,10.65 5.943,10.598 6.5,11.455C6.914,12.092 6.768,13.21 6.205,13.709C5.909,13.97 5.78,14.016 5.342,14.013C5.059,14.01 4.705,13.942 4.556,13.86Z"
android:fillColor="#0C090A"
android:fillType="evenOdd"/>
</group>
</vector>

View file

@ -25,11 +25,12 @@ class Debouncer {
coroutineScope: CoroutineScope,
waitMs: Long = 300L,
context: CoroutineContext = EmptyCoroutineContext,
forceUpdate: Boolean = false,
destinationFunction: suspend () -> Unit,
) {
debounceJob?.cancel()
debounceJob = coroutineScope.launch(context) {
delay(waitMs)
if (!forceUpdate) delay(waitMs)
destinationFunction.invoke()
}
}