Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-25 16:27:20 +04:00
parent 9b1c90cf12
commit 2f7bcb9cbb
15 changed files with 316 additions and 88 deletions

View file

@ -1,16 +0,0 @@
package com.tangem.tap.common
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
private val uiHandler = Handler(Looper.getMainLooper())
private val backgroundHandler = Handler(HandlerThread("AppMainHandlerThread").apply { start() }.looper)
fun postUi(ms: Long = 0, func: Runnable) {
if (ms == 0L) uiHandler.post { func.run() } else uiHandler.postDelayed(func, ms)
}
fun postUiDelayBg(ms: Long, func: Runnable) {
backgroundHandler.postDelayed({ uiHandler.post(func) }, ms)
}

View file

@ -11,7 +11,7 @@ class ScanFailsDialogAnalytics(button: Buttons, source: AnalyticsParam.ScreensSo
),
) {
enum class Buttons(val event: String) {
TRY_AGAIN("Try again button"),
HOW_TO_SCAN("Button blog"),
TRY_AGAIN("Button try again"),
}
}

View file

@ -1,5 +0,0 @@
package com.tangem.tap.common.entities
import com.tangem.tap.common.toggleWidget.WidgetState
enum class ProgressState : WidgetState { Loading, Done, Error }

View file

@ -1,19 +0,0 @@
package com.tangem.tap.common.extensions
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.transition.AutoTransition
import androidx.transition.Transition
import androidx.transition.TransitionManager
/**
[REDACTED_AUTHOR]
*/
fun ViewGroup.inflate(viewToInflate: Int, attachToRoot: Boolean = false): View {
return LayoutInflater.from(context).inflate(viewToInflate, this, attachToRoot)
}
fun ViewGroup.beginDelayedTransition(transition: Transition = AutoTransition()) {
TransitionManager.beginDelayedTransition(this, transition)
}

View file

@ -1,13 +0,0 @@
package com.tangem.tap.common.toggleWidget
import android.view.View
/**
[REDACTED_AUTHOR]
*/
interface WidgetState
interface ViewStateWidget {
val mainView: View
fun changeState(state: WidgetState)
}

View file

@ -1,24 +0,0 @@
package com.tangem.tap.common.transitions
import androidx.transition.ChangeBounds
import androidx.transition.ChangeTransform
import androidx.transition.Fade
import androidx.transition.TransitionSet
class HomeToOnboardingTransition : TransitionSet() {
init {
ordering = ORDERING_TOGETHER
addTransition(Fade())
addTransition(ChangeTransform())
addTransition(ChangeBounds())
}
}
class InternalNoteLayoutTransition : TransitionSet() {
init {
ordering = ORDERING_TOGETHER
addTransition(ChangeTransform())
addTransition(ChangeBounds())
addTransition(Fade())
}
}

View file

@ -0,0 +1,42 @@
package com.tangem.tap.features.scanfails
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.card.ScanFailsRequester
import com.tangem.tap.features.scanfails.ui.ScanFailsDialogContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class ScanFailsComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Unit,
) : AppComponentContext by appComponentContext, ComposableContentComponent, ScanFailsRequester {
private val model: ScanFailsModel = getOrCreateModel(params)
override suspend fun show(source: ScanFailsRequester.Source): ScanFailsRequester.Result {
model.show(source)
return model.waitResult()
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
if (state.isShown) {
ScanFailsDialogContent(state = state)
}
}
@AssistedFactory
interface Factory : ComponentFactory<Unit, ScanFailsComponent> {
override fun create(context: AppComponentContext, params: Unit): ScanFailsComponent
}
}

View file

@ -0,0 +1,84 @@
package com.tangem.tap.features.scanfails
import com.tangem.common.TangemBlogUrlBuilder
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.card.ScanFailsRequester
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.tap.common.analytics.events.ScanFailsDialogAnalytics
import com.tangem.tap.features.scanfails.ui.ScanFailsUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
internal class ScanFailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val urlOpener: UrlOpener,
) : Model() {
private val result = MutableStateFlow<ScanFailsRequester.Result?>(null)
val uiState: StateFlow<ScanFailsUM>
field = MutableStateFlow(ScanFailsUM(onDismiss = ::dismiss))
fun show(source: ScanFailsRequester.Source) {
val analyticsSource = source.toAnalyticsSource()
result.value = null
uiState.update {
ScanFailsUM(
isShown = true,
onHowToScan = { onHowToScan(analyticsSource) },
onRequestSupport = { onRequestSupport(analyticsSource) },
onDismiss = ::dismiss,
)
}
}
suspend fun waitResult(): ScanFailsRequester.Result {
return result.filterNotNull().first().also { result.value = null }
}
fun dismiss() {
result.value = ScanFailsRequester.Result.Dismissed
uiState.update { it.copy(isShown = false) }
}
private fun onHowToScan(source: AnalyticsParam.ScreensSources) {
analyticsEventHandler.send(
ScanFailsDialogAnalytics(
button = ScanFailsDialogAnalytics.Buttons.HOW_TO_SCAN,
source = source,
),
)
modelScope.launch {
urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.HowToScan))
}
}
private fun onRequestSupport(source: AnalyticsParam.ScreensSources) {
analyticsEventHandler.send(Basic.ButtonSupport(source))
modelScope.launch {
sendFeedbackEmailUseCase(type = FeedbackEmailType.ScanningProblem)
}
}
private fun ScanFailsRequester.Source.toAnalyticsSource(): AnalyticsParam.ScreensSources = when (this) {
ScanFailsRequester.Source.MAIN -> AnalyticsParam.ScreensSources.Main
ScanFailsRequester.Source.SIGN_IN -> AnalyticsParam.ScreensSources.SignIn
ScanFailsRequester.Source.SETTINGS -> AnalyticsParam.ScreensSources.Settings
ScanFailsRequester.Source.INTRO -> AnalyticsParam.ScreensSources.Intro
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.tap.features.scanfails
import com.tangem.domain.card.ScanFailsRequester
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ScanFailsRequesterProxy @Inject constructor() : ScanFailsRequester {
val componentRequester = MutableStateFlow<ScanFailsRequester?>(null)
override suspend fun show(source: ScanFailsRequester.Source): ScanFailsRequester.Result {
return withTimeout(timeMillis = 1000) {
componentRequester.filterNotNull().first()
}.show(source)
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.tap.features.scanfails.di
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.Model
import com.tangem.domain.card.ScanFailsRequester
import com.tangem.tap.features.scanfails.ScanFailsComponent
import com.tangem.tap.features.scanfails.ScanFailsModel
import com.tangem.tap.features.scanfails.ScanFailsRequesterProxy
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ScanFailsModule {
@Binds
fun bindComponentFactory(impl: ScanFailsComponent.Factory): ComponentFactory<Unit, ScanFailsComponent>
@Binds
@IntoMap
@ClassKey(ScanFailsModel::class)
fun bindModel(model: ScanFailsModel): Model
@Binds
@Singleton
fun bindRequester(impl: ScanFailsRequesterProxy): ScanFailsRequester
}

View file

@ -0,0 +1,109 @@
package com.tangem.tap.features.scanfails.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.tangem.core.ui.extensions.LocalUserInteractionTracker
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.trackUserInteraction
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.wallet.R
@Composable
internal fun ScanFailsDialogContent(state: ScanFailsUM) {
val userInteractionTracker = LocalUserInteractionTracker.current
Dialog(
onDismissRequest = state.onDismiss,
properties = DialogProperties(dismissOnClickOutside = true),
) {
Column(
modifier = Modifier
.trackUserInteraction(userInteractionTracker)
.background(
color = TangemTheme.colors.background.primary,
shape = RoundedCornerShape(16.dp),
)
.padding(vertical = 16.dp, horizontal = 38.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text(
text = stringResourceSafe(R.string.common_warning),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.h3,
textAlign = TextAlign.Center,
)
Text(
text = stringResourceSafe(R.string.alert_troubleshooting_scan_card_title),
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
textAlign = TextAlign.Center,
)
DialogTextButton(
text = stringResourceSafe(R.string.alert_button_how_to_scan),
onClick = state.onHowToScan,
color = TangemTheme.colors.text.accent,
)
DialogTextButton(
text = stringResourceSafe(R.string.alert_button_request_support),
onClick = state.onRequestSupport,
color = TangemTheme.colors.text.accent,
)
DialogTextButton(
text = stringResourceSafe(R.string.common_cancel),
onClick = state.onDismiss,
color = TangemTheme.colors.text.warning,
)
}
}
}
@Composable
private fun DialogTextButton(text: String, onClick: () -> Unit, color: Color) {
Text(
text = text.uppercase(),
color = color,
style = TangemTheme.typography.button,
textAlign = TextAlign.Center,
modifier = Modifier.clickable(onClick = onClick),
)
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true, widthDp = 360, fontScale = 2f)
@Composable
private fun ScanFailsDialogContentPreview() {
TangemThemePreview {
ScanFailsDialogContent(
state = ScanFailsUM(
isShown = true,
onHowToScan = {},
onRequestSupport = {},
onDismiss = {},
),
)
}
}
// endregion Preview

View file

@ -0,0 +1,8 @@
package com.tangem.tap.features.scanfails.ui
internal data class ScanFailsUM(
val isShown: Boolean = false,
val onHowToScan: () -> Unit = {},
val onRequestSupport: () -> Unit = {},
val onDismiss: () -> Unit = {},
)