Updated on 2026-08-14

This commit is contained in:
Tangem 2024-06-11 16:48:02 +05:00
parent 6446a08369
commit 81362b8ae9
19 changed files with 608 additions and 23 deletions

View file

@ -0,0 +1,41 @@
package com.tangem.core.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
@Composable
fun BoxWithGradient(
modifier: Modifier = Modifier,
gradient: Brush = BottomGradient,
content: @Composable BoxScope.() -> Unit,
) {
val bottomInsetsPx = WindowInsets.navigationBars.getBottom(LocalDensity.current)
Box(modifier = modifier.fillMaxSize()) {
content()
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(TangemTheme.dimens.size164 + bottomInsetsPx.dp)
.background(gradient),
)
}
}
private val BottomGradient: Brush = Brush.verticalGradient(
colors = listOf(
TangemColorPalette.Black.copy(alpha = 0f),
TangemColorPalette.Black.copy(alpha = 0.75f),
TangemColorPalette.Black.copy(alpha = 0.95f),
TangemColorPalette.Black,
),
)

View file

@ -1,23 +1,52 @@
package com.tangem.core.ui.components.appbar
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.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.appbar.models.AdditionalButton
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
/**
* App bar with title and two additional buttons
*
* @param startButton button information attached to the left edge
* @param endButton button information attached to the right edge
*
* @see <a href = "https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?node-id=50%3A403&t=tRQ4KlzkjV7TCLZl-4">
* Figma Component</a>
*/
@Composable
fun AppBarWithAdditionalButtons(
text: TextReference,
modifier: Modifier = Modifier,
startButton: AdditionalButton? = null,
endButton: AdditionalButton? = null,
) {
AppBarWithAdditionalButtons(
text = text.resolveReference(),
startButton = startButton,
endButton = endButton,
modifier = modifier,
)
}
/**
* App bar with title and two additional buttons
@ -31,24 +60,33 @@ import com.tangem.core.ui.res.TangemTheme
@Composable
fun AppBarWithAdditionalButtons(
text: String,
modifier: Modifier = Modifier,
startButton: AdditionalButton? = null,
endButton: AdditionalButton? = null,
) {
Box(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.heightIn(TangemTheme.dimens.size56)
.padding(all = TangemTheme.dimens.spacing16),
) {
if (startButton != null) {
IconButton(modifier = Modifier.align(Alignment.CenterStart), onClick = startButton.onIconClicked) {
Icon(
painter = painterResource(id = startButton.iconRes),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size24),
tint = TangemTheme.colors.icon.primary1,
)
}
Icon(
painter = painterResource(id = startButton.iconRes),
contentDescription = null,
tint = TangemTheme.colors.icon.primary1,
modifier = Modifier
.size(TangemTheme.dimens.size24)
.align(Alignment.CenterStart)
.clickable(
onClick = startButton.onIconClicked,
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = androidx.compose.material.ripple.rememberRipple(
bounded = false,
radius = TangemTheme.dimens.size24 / 2,
),
),
)
}
Text(
@ -60,14 +98,23 @@ fun AppBarWithAdditionalButtons(
)
if (endButton != null) {
IconButton(modifier = Modifier.align(Alignment.CenterEnd), onClick = endButton.onIconClicked) {
Icon(
painter = painterResource(id = endButton.iconRes),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size24),
tint = TangemTheme.colors.icon.primary1,
)
}
Icon(
painter = painterResource(id = endButton.iconRes),
contentDescription = null,
tint = TangemTheme.colors.icon.primary1,
modifier = Modifier
.size(TangemTheme.dimens.size24)
.align(Alignment.CenterEnd)
.clickable(
onClick = endButton.onIconClicked,
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = androidx.compose.material.ripple.rememberRipple(
bounded = false,
radius = TangemTheme.dimens.size24 / 2,
),
),
)
}
}
}
@ -87,6 +134,7 @@ private fun Preview_AppBarWithAdditionalButtons() {
iconRes = R.drawable.ic_more_vertical_24,
onIconClicked = {},
),
modifier = Modifier.background(TangemTheme.colors.background.secondary),
)
}
}
@ -102,6 +150,7 @@ private fun Preview_AppBarWithOnlyStartButtons() {
iconRes = R.drawable.ic_scan_24,
onIconClicked = {},
),
modifier = Modifier.background(TangemTheme.colors.background.secondary),
)
}
}
@ -117,6 +166,7 @@ private fun Preview_AppBarWithOnlyEndButtons() {
iconRes = R.drawable.ic_more_vertical_24,
onIconClicked = {},
),
modifier = Modifier.background(TangemTheme.colors.background.secondary),
)
}
}

View file

@ -83,6 +83,7 @@ data class TangemDimens internal constructor(
val size96: Dp = 96.dp,
val size102: Dp = 102.dp,
val size108: Dp = 108.dp,
val size110: Dp = 110.dp,
val size116: Dp = 116.dp,
val size120: Dp = 120.dp,
val size142: Dp = 142.dp,

1
features/disclaimer/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,15 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.disclaimer.api"
}
dependencies {
/** AndroidX */
implementation(deps.androidx.fragment.ktx)
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.disclaimer.api
import androidx.fragment.app.Fragment
interface DisclaimerRouter {
fun entryFragment(): Fragment
companion object {
const val IS_TOS_ACCEPTED_KEY = "is_tos_accepted"
}
}

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

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,45 @@
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.features.disclaimer.impl"
}
dependencies {
/** AndroidX */
implementation(deps.androidx.fragment.ktx)
implementation(deps.androidx.appCompat)
implementation(deps.lifecycle.compose)
implementation(deps.androidx.activity.compose)
/** Compose */
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.accompanist.systemUiController)
implementation(deps.compose.accompanist.permission)
implementation(deps.compose.material3)
/** Core modules */
implementation(projects.core.ui)
implementation(projects.core.utils)
implementation(projects.core.featuretoggles)
implementation(projects.core.navigation)
/** Domain modules */
implementation(projects.domain.models)
implementation(projects.domain.card)
/** Feature modules */
implementation(projects.features.disclaimer.api)
/** DI */
implementation(deps.hilt.android)
implementation(project(":domain:settings"))
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,58 @@
package com.tangem.features.disclaimer.impl
import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.fragment.app.viewModels
import com.tangem.core.navigation.ReduxNavController
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.features.disclaimer.api.DisclaimerRouter
import com.tangem.features.disclaimer.impl.presentation.ui.DisclaimerScreen
import com.tangem.features.disclaimer.impl.presentation.viewmodel.DisclaimerViewModel
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
internal class DisclaimerFragment : ComposeFragment() {
@Inject
override lateinit var uiDependencies: UiDependencies
@Inject
lateinit var reduxNavController: ReduxNavController
private val viewModel by viewModels<DisclaimerViewModel>()
private val isTosAccepted: Boolean
get() = arguments?.getBoolean(DisclaimerRouter.IS_TOS_ACCEPTED_KEY) ?: false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycle.addObserver(viewModel)
}
@Composable
override fun ScreenContent(modifier: Modifier) {
val systemBarsColor = TangemTheme.colors.background.primary
BackHandler {
if (isTosAccepted) {
reduxNavController.popBackStack()
} else {
requireActivity().finish()
}
}
SystemBarsEffect {
setSystemBarsColor(systemBarsColor)
}
DisclaimerScreen(viewModel.state, reduxNavController::popBackStack)
}
companion object {
/** Create disclaimer fragment instance */
fun create(): DisclaimerFragment = DisclaimerFragment()
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.features.disclaimer.impl.di
import com.tangem.core.navigation.ReduxNavController
import com.tangem.features.disclaimer.api.DisclaimerRouter
import com.tangem.features.disclaimer.impl.navigation.DefaultDisclaimerRouter
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.scopes.ActivityScoped
/**
* DI module provides implementation of [DisclaimerRouter]
*/
@Module
@InstallIn(ActivityComponent::class)
object DisclaimerRouterModule {
@Provides
@ActivityScoped
fun provideDisclaimerRouter(reduxNavController: ReduxNavController): DisclaimerRouter {
return DefaultDisclaimerRouter(reduxNavController)
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.features.disclaimer.impl.navigation
import androidx.fragment.app.Fragment
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
import com.tangem.core.navigation.ReduxNavController
import com.tangem.features.disclaimer.impl.DisclaimerFragment
import javax.inject.Inject
internal class DefaultDisclaimerRouter @Inject constructor(
private val reduxNavController: ReduxNavController,
) : InnerDisclaimerRouter {
override fun entryFragment(): Fragment = DisclaimerFragment.create()
override fun openPushNotificationPermission() {
// todo [REDACTED_JIRA]
}
override fun openHome() {
reduxNavController.navigate(action = NavigationAction.NavigateTo(screen = AppScreen.Home))
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.disclaimer.impl.navigation
import com.tangem.features.disclaimer.api.DisclaimerRouter
internal interface InnerDisclaimerRouter : DisclaimerRouter {
fun openPushNotificationPermission()
fun openHome()
}

View file

@ -0,0 +1,16 @@
package com.tangem.features.disclaimer.impl.presentation.state
internal data class DisclaimerState(
val url: String,
val isTosAccepted: Boolean,
val onAccept: (Boolean) -> Unit,
)
internal object DummyDisclaimer {
val state = DisclaimerState(
url = "https://tangem.com/tangem_tos.html",
isTosAccepted = false,
onAccept = {},
)
}

View file

@ -0,0 +1,174 @@
package com.tangem.features.disclaimer.impl.presentation.ui
import android.Manifest
import android.content.res.Configuration
import android.os.Build
import android.view.View
import android.view.ViewGroup
import android.webkit.WebView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.appbar.AppBarWithAdditionalButtons
import com.tangem.core.ui.components.appbar.models.AdditionalButton
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.disclaimer.impl.R
import com.tangem.features.disclaimer.impl.presentation.state.DisclaimerState
import com.tangem.features.disclaimer.impl.presentation.state.DummyDisclaimer
@Composable
internal fun DisclaimerScreen(state: DisclaimerState, onBackClick: () -> Unit) {
val bottomPadding = if (state.isTosAccepted) TangemTheme.dimens.spacing16 else TangemTheme.dimens.size64
Box(
modifier = Modifier
.statusBarsPadding()
.background(TangemColorPalette.Dark6),
) {
Column(
modifier = Modifier
.navigationBarsPadding()
.padding(bottom = bottomPadding),
) {
AppBarWithAdditionalButtons(
text = resourceReference(R.string.disclaimer_title),
startButton = AdditionalButton(
iconRes = R.drawable.ic_back_24,
onIconClicked = onBackClick,
).takeIf { state.isTosAccepted },
)
DisclaimerContent(state.url)
}
if (!state.isTosAccepted) {
DisclaimerButton(state.onAccept)
}
}
}
@Composable
private fun ColumnScope.DisclaimerContent(url: String) {
val transparent = Color.Transparent
val progressState = remember { mutableStateOf(ProgressState.Loading) }
val webClient = remember { DisclaimerWebViewClient(progressState) }
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
AndroidView(
factory = {
WebView(it).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
setBackgroundColor(transparent.toArgb())
settings.allowFileAccess = false
settings.javaScriptEnabled = false
overScrollMode = View.OVER_SCROLL_NEVER
webViewClient = webClient
loadUrl(url)
}
},
)
when (progressState.value) {
ProgressState.Loading -> CircularProgressIndicator(
color = TangemTheme.colors.icon.informative,
modifier = Modifier
.align(Alignment.Center)
.padding(TangemTheme.dimens.spacing8),
)
ProgressState.Error -> Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing16)
.align(Alignment.Center),
) {
Text(
text = stringResource(id = R.string.disclaimer_error_loading),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.constantWhite,
)
PrimaryButton(
text = stringResource(R.string.common_retry),
onClick = { webClient.reset() },
)
}
else -> Unit
}
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun BoxScope.DisclaimerButton(onAccept: (Boolean) -> Unit) {
val shouldAskPushPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS).status.isGranted
} else {
true
}
val bottomInsetsPx = WindowInsets.navigationBars.getBottom(LocalDensity.current)
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(TangemTheme.dimens.size110 + bottomInsetsPx.dp)
.background(
Brush.verticalGradient(
colors = listOf(
TangemTheme.colors.background.primary.copy(alpha = 0f),
TangemTheme.colors.background.primary.copy(alpha = 0.75f),
TangemTheme.colors.background.primary.copy(alpha = 0.95f),
TangemTheme.colors.background.primary,
),
),
),
)
PrimaryButton(
text = stringResource(id = R.string.common_accept),
onClick = { onAccept(shouldAskPushPermission) },
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
)
.fillMaxWidth(),
)
}
// region Preview
@Preview(showBackground = true, widthDp = 360, heightDp = 800)
@Preview(showBackground = true, widthDp = 360, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun DisclaimerScreen_Preview() {
TangemThemePreview {
DisclaimerScreen(state = DummyDisclaimer.state, onBackClick = {})
}
}
// endregion

View file

@ -0,0 +1,62 @@
package com.tangem.features.disclaimer.impl.presentation.ui
import android.graphics.Bitmap
import android.webkit.*
import androidx.compose.runtime.MutableState
internal enum class ProgressState {
Loading,
Done,
Error,
}
internal class DisclaimerWebViewClient(private val progressState: MutableState<ProgressState>) : WebViewClient() {
private var loadingUrl: String? = null
private var loadedUrl: String? = null
fun reset() {
loadingUrl = null
loadedUrl = null
progressState.value = ProgressState.Loading
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
if (loadingUrl != url) progressState.value = ProgressState.Loading
loadingUrl = url
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
if (loadedUrl != url) progressState.value = ProgressState.Done
loadedUrl = url
}
override fun onReceivedError(view: WebView?, resourceRequest: WebResourceRequest?, error: WebResourceError?) {
super.onReceivedError(view, resourceRequest, error)
error?.let { progressState.value = ProgressState.Error }
}
override fun onReceivedHttpError(
view: WebView?,
resourceRequest: WebResourceRequest?,
errorResponse: WebResourceResponse?,
) {
super.onReceivedHttpError(view, resourceRequest, errorResponse)
if (resourceRequest != null && errorResponse != null) {
val isDifferentUrl = resourceRequest.url?.toString() != loadingUrl
val isSuccessCode = errorResponse.statusCode < RESPONSE_USER_ERROR_STATUS_CODE
val isNotDone = progressState.value != ProgressState.Done
if (isDifferentUrl || isSuccessCode || isNotDone) return
progressState.value = ProgressState.Error
}
}
companion object {
private const val RESPONSE_USER_ERROR_STATUS_CODE = 400
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.features.disclaimer.impl.presentation.viewmodel
internal interface DisclaimerClickIntents {
fun onAccept(shouldAskPushPermission: Boolean)
}

View file

@ -0,0 +1,42 @@
package com.tangem.features.disclaimer.impl.presentation.viewmodel
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.domain.card.repository.CardRepository
import com.tangem.features.disclaimer.api.DisclaimerRouter.Companion.IS_TOS_ACCEPTED_KEY
import com.tangem.features.disclaimer.impl.navigation.DefaultDisclaimerRouter
import com.tangem.features.disclaimer.impl.presentation.state.DisclaimerState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class DisclaimerViewModel @Inject constructor(
private val cardRepository: CardRepository,
private val disclaimerRouter: DefaultDisclaimerRouter,
savedStateHandle: SavedStateHandle,
) : ViewModel(), DefaultLifecycleObserver, DisclaimerClickIntents {
private val isTosAccepted: Boolean = savedStateHandle[IS_TOS_ACCEPTED_KEY] ?: false
val state: DisclaimerState
get() = DisclaimerState(
onAccept = ::onAccept,
url = DISCLAIMER_URL,
isTosAccepted = isTosAccepted,
)
override fun onAccept(shouldAskPushPermission: Boolean) {
viewModelScope.launch {
cardRepository.acceptTangemTOS()
// todo [REDACTED_JIRA]
disclaimerRouter.openHome()
}
}
private companion object {
const val DISCLAIMER_URL = "https://tangem.com/tangem_tos.html"
}
}

View file

@ -168,6 +168,7 @@ compose-navigation-hilt = { module = "androidx.hilt:hilt-navigation-compose", ve
compose-accompanist-appCompatTheme = { module = "com.google.accompanist:accompanist-appcompat-theme", version.ref = "compose-accompanist" }
compose-accompanist-systemUiController = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "compose-accompanist" }
compose-accompanist-webView = { module = "com.google.accompanist:accompanist-webview", version.ref = "compose-accompanist" }
compose-accompanist-permission = { module = "com.google.accompanist:accompanist-permissions", version.ref = "compose-accompanist" }
compose-paging = { module = "androidx.paging:paging-compose", version.ref = "compose-paging" }
compose-reorderable = { module = "org.burnoutcrew.composereorderable:reorderable", version.ref = "compose-reorderable" }
# endregion Compose

View file

@ -176,6 +176,9 @@ include(":features:staking:impl")
include(":features:details:api")
include(":features:details:impl")
include(":features:disclaimer:api")
include(":features:disclaimer:impl")
// endregion Feature modules
// region Domain modules