Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-22 11:17:28 +02:00
parent 6031a97ffe
commit 8d0f11d10b
67 changed files with 1527 additions and 41 deletions

View file

@ -0,0 +1,14 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.rating.api"
}
dependencies {
implementation(projects.core.decompose)
implementation(projects.core.ui)
}

View file

@ -0,0 +1,16 @@
package com.tangem.features.rating
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent
interface RatingComponent : ComposableContentComponent {
class Params(
val onLoadRating: suspend () -> Int?,
val onSubmitRating: suspend (rating: Int, feedback: String) -> Unit,
)
interface Factory {
fun create(context: AppComponentContext, params: Params): RatingComponent
}
}

View file

@ -0,0 +1,37 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.feature.rating.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
implementation(projects.features.rating.api)
implementation(projects.core.decompose)
implementation(projects.core.res)
implementation(projects.core.ui)
implementation(projects.core.utils)
implementation(deps.compose.foundation)
implementation(deps.compose.material3)
implementation(deps.compose.ui)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
}

View file

@ -0,0 +1,33 @@
package com.tangem.feature.rating
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.model.getOrCreateModel
import com.tangem.feature.rating.model.RatingModel
import com.tangem.feature.rating.ui.RatingBlock
import com.tangem.features.rating.RatingComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultRatingComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: RatingComponent.Params,
) : RatingComponent, AppComponentContext by appComponentContext {
private val model: RatingModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
RatingBlock(state = state, modifier = modifier)
}
@AssistedFactory
interface Factory : RatingComponent.Factory {
override fun create(context: AppComponentContext, params: RatingComponent.Params): DefaultRatingComponent
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.feature.rating.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.feature.rating.DefaultRatingComponent
import com.tangem.feature.rating.model.RatingModel
import com.tangem.features.rating.RatingComponent
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
@InstallIn(SingletonComponent::class)
@Module
internal interface RatingFeatureModule {
@Binds
@Singleton
fun bindFactory(factory: DefaultRatingComponent.Factory): RatingComponent.Factory
}
@Module
@InstallIn(ModelComponent::class)
internal interface RatingModelModule {
@Binds
@IntoMap
@ClassKey(RatingModel::class)
fun bindModel(model: RatingModel): Model
}

View file

@ -0,0 +1,137 @@
package com.tangem.feature.rating.model
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.feature.rating.ui.RatingFeedbackBS
import com.tangem.feature.rating.ui.RatingUM
import com.tangem.features.rating.RatingComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
internal class RatingModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
private val params: RatingComponent.Params = paramsContainer.require()
val state: StateFlow<RatingUM>
field = MutableStateFlow(
RatingUM(
state = RatingUM.RatingState.Loading,
feedbackBottomSheet = TangemBottomSheetConfig.Empty,
onRatingSelected = ::onRatingSelected,
),
)
init {
loadRating()
}
fun onRatingSelected(rating: Int) {
state.update { current ->
val ratingState = current.state as? RatingUM.RatingState.Unrated ?: return@update current
current.copy(
state = ratingState.copy(selectedRating = rating),
feedbackBottomSheet = buildFeedbackBottomSheet(feedbackText = "", isSubmitting = false),
)
}
}
private fun onFeedbackChanged(text: String) {
state.update { current ->
val bs = current.feedbackBottomSheet
val content = bs.content as? RatingFeedbackBS ?: return@update current
current.copy(feedbackBottomSheet = bs.copy(content = content.copy(feedbackText = text)))
}
}
private fun onDismissFeedbackBottomSheet() {
state.update { current ->
current.copy(feedbackBottomSheet = current.feedbackBottomSheet.copy(isShown = false))
}
}
private fun onSubmit() {
val current = state.value
val ratingState = current.state as? RatingUM.RatingState.Unrated ?: return
val selectedRating = ratingState.selectedRating ?: return
val content = current.feedbackBottomSheet.content as? RatingFeedbackBS ?: return
state.update {
current.copy(
feedbackBottomSheet = current.feedbackBottomSheet.copy(
content = content.copy(isSubmitting = true),
),
)
}
modelScope.launch {
try {
params.onSubmitRating(selectedRating, content.feedbackText)
state.update { um ->
um.copy(
state = RatingUM.RatingState.AlreadyRated(selectedRating),
feedbackBottomSheet = um.feedbackBottomSheet.copy(isShown = false),
)
}
} catch (e: Exception) {
TangemLogger.e("RatingModel: onSubmitRating failed", e)
uiMessageSender.send(SnackbarMessage(message = resourceReference(R.string.common_something_went_wrong)))
state.update { um ->
val bsContent = um.feedbackBottomSheet.content as? RatingFeedbackBS ?: return@update um
um.copy(
feedbackBottomSheet = um.feedbackBottomSheet.copy(
content = bsContent.copy(isSubmitting = false),
),
)
}
}
}
}
private fun buildFeedbackBottomSheet(feedbackText: String, isSubmitting: Boolean): TangemBottomSheetConfig {
return TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::onDismissFeedbackBottomSheet,
content = RatingFeedbackBS(
feedbackText = feedbackText,
isSubmitting = isSubmitting,
onFeedbackChanged = ::onFeedbackChanged,
onDismiss = ::onDismissFeedbackBottomSheet,
onSubmit = ::onSubmit,
),
)
}
private fun loadRating() = modelScope.launch {
val existingRating = try {
params.onLoadRating()
} catch (e: Exception) {
TangemLogger.e("RatingModel: onLoadRating failed", e)
null
}
state.update { current ->
current.copy(
state = if (existingRating != null) {
RatingUM.RatingState.AlreadyRated(existingRating)
} else {
RatingUM.RatingState.Unrated(selectedRating = null)
},
)
}
}
}

View file

@ -0,0 +1,103 @@
package com.tangem.feature.rating.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
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
private const val STARS_COUNT = 5
@Composable
fun RatingBlock(state: RatingUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.padding(TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (val ratingState = state.state) {
is RatingUM.RatingState.Loading -> RatingLoadingState()
is RatingUM.RatingState.Unrated -> UnratedState(
state = ratingState,
onRatingSelect = state.onRatingSelected,
)
is RatingUM.RatingState.AlreadyRated -> AlreadyRatedState(rating = ratingState.rating)
}
}
RatingFeedbackBottomSheet(config = state.feedbackBottomSheet)
}
@Composable
private fun RatingLoadingState() {
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens.size48),
)
}
@Composable
private fun UnratedState(state: RatingUM.RatingState.Unrated, onRatingSelect: (Int) -> Unit) {
Text(
text = stringResourceSafe(R.string.swapping_rate_experience_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
StarRow(
selectedRating = state.selectedRating,
isEnabled = true,
onRatingSelect = onRatingSelect,
)
}
@Composable
private fun AlreadyRatedState(rating: Int) {
Text(
text = stringResourceSafe(R.string.swapping_rate_experience_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
StarRow(
selectedRating = rating,
isEnabled = false,
onRatingSelect = {},
)
}
@Composable
private fun StarRow(selectedRating: Int?, isEnabled: Boolean, onRatingSelect: (Int) -> Unit) {
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6)) {
for (star in 1..STARS_COUNT) {
val isFilled = selectedRating != null && star <= selectedRating
IconButton(
onClick = { if (isEnabled) onRatingSelect(star) },
enabled = isEnabled,
) {
Icon(
painter = painterResource(R.drawable.ic_rating_star_24),
contentDescription = null,
tint = if (isFilled) {
TangemTheme.colors.icon.attention
} else {
TangemTheme.colors.icon.inactive
},
modifier = Modifier.size(TangemTheme.dimens.size32),
)
}
}
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.feature.rating.ui
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
internal data class RatingFeedbackBS(
val feedbackText: String,
val isSubmitting: Boolean,
val onFeedbackChanged: (String) -> Unit,
val onDismiss: () -> Unit,
val onSubmit: () -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,159 @@
package com.tangem.feature.rating.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import com.tangem.core.ui.R
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.buttons.small.TangemIconButton
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@Composable
@Suppress("LongMethod")
internal fun RatingFeedbackBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<RatingFeedbackBS>(
config = config,
containerColor = TangemTheme.colors.background.secondary,
addBottomInsets = false,
title = { content ->
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing8,
),
horizontalArrangement = Arrangement.End,
) {
TangemIconButton(
iconRes = R.drawable.ic_close_24,
onClick = content.onDismiss,
)
}
Box(
modifier = Modifier
.size(TangemTheme.dimens.size56)
.clip(CircleShape)
.background(TangemTheme.colors.icon.attention.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(R.drawable.ic_rating_star_24),
contentDescription = null,
tint = TangemTheme.colors.icon.attention,
modifier = Modifier.size(TangemTheme.dimens.size32),
)
}
SpacerH12()
Text(
text = stringResourceSafe(R.string.swapping_rate_feedback_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
SpacerH16()
}
},
content = { content ->
Column(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.padding(top = TangemTheme.dimens.spacing16)
.navigationBarsPadding(),
) {
FeedbackTextField(
value = content.feedbackText,
onValueChange = content.onFeedbackChanged,
)
SpacerH16()
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.swapping_rate_feedback_submit),
onClick = content.onSubmit,
showProgress = content.isSubmitting,
)
SpacerH16()
}
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FeedbackTextField(value: String, onValueChange: (String) -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
val fieldShape = RoundedCornerShape(TangemTheme.dimens.radius14)
val colors = TextFieldDefaults.colors().copy(
focusedContainerColor = TangemTheme.colors.field.focused,
unfocusedContainerColor = TangemTheme.colors.field.focused,
focusedTextColor = TangemTheme.colors.text.primary1,
unfocusedTextColor = TangemTheme.colors.text.primary1,
cursorColor = TangemTheme.colors.icon.primary1,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
)
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = TangemTheme.dimens.size48),
textStyle = TangemTheme.typography.body1.copy(color = TangemTheme.colors.text.primary1),
cursorBrush = SolidColor(TangemTheme.colors.icon.primary1),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
maxLines = 3,
singleLine = false,
minLines = 3,
interactionSource = interactionSource,
decorationBox = { innerTextField ->
TextFieldDefaults.DecorationBox(
value = value,
innerTextField = innerTextField,
enabled = true,
singleLine = false,
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
shape = fieldShape,
colors = colors,
contentPadding = PaddingValues(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
placeholder = {
Text(
text = stringResourceSafe(R.string.swapping_rate_feedback_placeholder),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.tertiary,
)
},
)
},
)
}

View file

@ -0,0 +1,15 @@
package com.tangem.feature.rating.ui
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
data class RatingUM(
val state: RatingState,
val feedbackBottomSheet: TangemBottomSheetConfig,
val onRatingSelected: (Int) -> Unit,
) {
sealed interface RatingState {
data object Loading : RatingState
data class Unrated(val selectedRating: Int?) : RatingState
data class AlreadyRated(val rating: Int) : RatingState
}
}

View file

@ -0,0 +1,137 @@
package com.tangem.feature.rating.model
import com.google.common.truth.Truth.assertThat
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.feature.rating.ui.RatingFeedbackBS
import com.tangem.feature.rating.ui.RatingUM
import com.tangem.features.rating.RatingComponent
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class RatingModelTest {
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
private fun buildModel(
onLoadRating: suspend () -> Int? = { null },
onSubmitRating: suspend (Int, String) -> Unit = { _, _ -> },
): RatingModel {
val params = RatingComponent.Params(
onLoadRating = onLoadRating,
onSubmitRating = onSubmitRating,
)
return RatingModel(
dispatchers = TestingCoroutineDispatcherProvider(),
paramsContainer = MutableParamsContainer(params),
uiMessageSender = uiMessageSender,
)
}
private val RatingModel.ratingState get() = state.value.state
private val RatingModel.feedbackContent get() = state.value.feedbackBottomSheet.content as? RatingFeedbackBS
@Test
fun `initial state is Loading before onLoadRating completes`() = runTest {
val deferred = CompletableDeferred<Int?>()
val model = buildModel(onLoadRating = { deferred.await() })
assertThat(model.ratingState).isInstanceOf(RatingUM.RatingState.Loading::class.java)
deferred.complete(null)
assertThat(model.ratingState).isInstanceOf(RatingUM.RatingState.Unrated::class.java)
}
@Test
fun `state is Unrated with no selection when onLoadRating returns null`() = runTest {
val model = buildModel(onLoadRating = { null })
val unrated = model.ratingState as RatingUM.RatingState.Unrated
assertThat(unrated.selectedRating).isNull()
assertThat(model.state.value.feedbackBottomSheet.isShown).isFalse()
}
@Test
fun `state is AlreadyRated when onLoadRating returns a rating`() = runTest {
val model = buildModel(onLoadRating = { 4 })
assertThat(model.ratingState).isEqualTo(RatingUM.RatingState.AlreadyRated(rating = 4))
}
@Test
fun `onRatingSelected updates selectedRating and shows feedback bottom sheet`() = runTest {
val model = buildModel(onLoadRating = { null })
model.onRatingSelected(3)
val unrated = model.ratingState as RatingUM.RatingState.Unrated
assertThat(unrated.selectedRating).isEqualTo(3)
assertThat(model.state.value.feedbackBottomSheet.isShown).isTrue()
}
@Test
fun `onRatingSelected is no-op when state is not Unrated`() = runTest {
val model = buildModel(onLoadRating = { 4 })
model.onRatingSelected(3)
assertThat(model.ratingState).isEqualTo(RatingUM.RatingState.AlreadyRated(rating = 4))
assertThat(model.state.value.feedbackBottomSheet.isShown).isFalse()
}
@Test
fun `onFeedbackChanged updates feedbackText in bottom sheet content`() = runTest {
val model = buildModel(onLoadRating = { null })
model.onRatingSelected(4)
model.feedbackContent!!.onFeedbackChanged("Great service!")
assertThat(model.feedbackContent!!.feedbackText).isEqualTo("Great service!")
}
@Test
fun `onSubmit calls onSubmitRating with correct args`() = runTest {
val submitMock: suspend (Int, String) -> Unit = mockk(relaxed = true)
val model = buildModel(onLoadRating = { null }, onSubmitRating = submitMock)
model.onRatingSelected(5)
model.feedbackContent!!.onFeedbackChanged("Excellent!")
model.feedbackContent!!.onSubmit()
coVerify(exactly = 1) { submitMock(5, "Excellent!") }
}
@Test
fun `onSubmit transitions to AlreadyRated and hides bottom sheet on success`() = runTest {
val model = buildModel(onLoadRating = { null }, onSubmitRating = { _, _ -> })
model.onRatingSelected(4)
model.feedbackContent!!.onSubmit()
assertThat(model.ratingState).isEqualTo(RatingUM.RatingState.AlreadyRated(rating = 4))
assertThat(model.state.value.feedbackBottomSheet.isShown).isFalse()
}
@Test
fun `onSubmit resets isSubmitting on failure`() = runTest {
val model = buildModel(
onLoadRating = { null },
onSubmitRating = { _, _ -> error("network error") },
)
model.onRatingSelected(3)
model.feedbackContent!!.onSubmit()
assertThat(model.feedbackContent!!.isSubmitting).isFalse()
}
@Test
fun `onSubmit shows snackbar on failure`() = runTest {
val model = buildModel(
onLoadRating = { null },
onSubmitRating = { _, _ -> error("network error") },
)
model.onRatingSelected(3)
model.feedbackContent!!.onSubmit()
verify(exactly = 1) { uiMessageSender.send(ofType<SnackbarMessage>()) }
}
@Test
fun `onSubmit is no-op when no rating selected`() = runTest {
val submitMock: suspend (Int, String) -> Unit = mockk(relaxed = true)
val model = buildModel(onLoadRating = { null }, onSubmitRating = submitMock)
// open BS without selecting rating (edge case - shouldn't happen in practice)
// just verify submit does nothing without a selected rating
coVerify(exactly = 0) { submitMock(any(), any()) }
}
}

View file

@ -5,4 +5,5 @@ interface SwapFeatureToggles {
val isSwapIntegratedApproveEnabled: Boolean
val isSwapAbEnabled: Boolean
val isSwapProviderFilterEnabled: Boolean
val isSwapRateExperienceEnabled: Boolean
}

View file

@ -27,6 +27,7 @@ dependencies {
/** Network */
implementation(deps.retrofit)
implementation(deps.retrofit.moshi)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.arrow.core)

View file

@ -0,0 +1,69 @@
package com.tangem.feature.swap
import arrow.core.Either
import com.tangem.datasource.api.surveysparrow.SurveySparrowApi
import com.tangem.datasource.api.surveysparrow.models.CreateSurveySparrowResponseBody
import com.tangem.datasource.api.surveysparrow.models.SurveySparrowAnswerDto
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
import com.tangem.feature.swap.domain.models.domain.ExistingRating
import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams
import org.json.JSONObject
internal class DefaultSwapFeedbackRepository(
private val api: SurveySparrowApi,
private val surveyId: Long,
private val ratingQuestionId: Long,
private val feedbackQuestionId: Long,
) : SwapFeedbackRepository {
override suspend fun getRating(txExternalId: String): Either<Throwable, ExistingRating?> {
return Either.catch {
val responses = api.getResponses(
surveyId = surveyId,
variables = JSONObject().put("tx_external_id", txExternalId).toString(),
limit = 1,
)
val ratingAnswer = responses.data
.firstOrNull()
?.answers
?.firstOrNull { answer ->
when (val id = answer.questionId) {
is Number -> id.toLong() == ratingQuestionId
else -> false
}
}
?.answer
?.let { v ->
when (v) {
is Number -> v.toInt()
is String -> v.toIntOrNull()
else -> null
}
}
if (ratingAnswer != null) ExistingRating(ratingAnswer) else null
}
}
override suspend fun submitFeedback(params: SwapFeedbackParams): Either<Throwable, Unit> {
return Either.catch {
api.createResponse(
CreateSurveySparrowResponseBody(
surveyId = surveyId,
answers = buildList {
add(SurveySparrowAnswerDto(ratingQuestionId, params.rating.toString()))
if (params.feedback.isNotEmpty()) {
add(SurveySparrowAnswerDto(feedbackQuestionId, params.feedback))
}
},
variables = mapOf(
"tx_external_id" to params.txExternalId,
"provider_name" to params.providerName,
"tx_url" to params.txUrl,
"user_wallet_id" to params.userWalletIdHash,
),
),
)
}
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.feature.swap
import arrow.core.Either
import arrow.core.right
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
import com.tangem.feature.swap.domain.models.domain.ExistingRating
import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams
internal class NoOpSwapFeedbackRepository : SwapFeedbackRepository {
override suspend fun getRating(txExternalId: String): Either<Throwable, ExistingRating?> = null.right()
override suspend fun submitFeedback(params: SwapFeedbackParams): Either<Throwable, Unit> = Unit.right()
}

View file

@ -5,16 +5,21 @@ import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
import com.tangem.datasource.api.surveysparrow.SurveySparrowApi
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.feature.swap.DefaultSwapFeedbackRepository
import com.tangem.feature.swap.DefaultSwapRepository
import com.tangem.feature.swap.NoOpSwapFeedbackRepository
import com.tangem.feature.swap.DefaultSwapTransactionRepository
import com.tangem.feature.swap.converters.ErrorsDataConverter
import com.tangem.feature.swap.domain.SwapTransactionRepository
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -75,4 +80,20 @@ internal class SwapDataModule {
val jsonAdapter = moshi.adapter(ExpressErrorResponse::class.java)
return ErrorsDataConverter(jsonAdapter)
}
@Provides
@Singleton
internal fun provideSwapFeedbackRepository(
api: SurveySparrowApi,
environmentConfig: EnvironmentConfig,
): SwapFeedbackRepository {
val rating = environmentConfig.surveySparrowSwapRating
?: return NoOpSwapFeedbackRepository()
return DefaultSwapFeedbackRepository(
api = api,
surveyId = rating.surveyId,
ratingQuestionId = rating.ratingQuestionId,
feedbackQuestionId = rating.feedbackQuestionId,
)
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.feature.swap.domain
import arrow.core.Either
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
import com.tangem.feature.swap.domain.models.domain.ExistingRating
import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams
import javax.inject.Inject
class SwapFeedbackUseCase @Inject constructor(
private val repository: SwapFeedbackRepository,
) {
suspend fun getExistingRating(txExternalId: String): Either<Throwable, ExistingRating?> =
repository.getRating(txExternalId)
suspend fun submit(params: SwapFeedbackParams): Either<Throwable, Unit> = repository.submitFeedback(params)
}

View file

@ -0,0 +1,10 @@
package com.tangem.feature.swap.domain.api
import arrow.core.Either
import com.tangem.feature.swap.domain.models.domain.ExistingRating
import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams
interface SwapFeedbackRepository {
suspend fun getRating(txExternalId: String): Either<Throwable, ExistingRating?>
suspend fun submitFeedback(params: SwapFeedbackParams): Either<Throwable, Unit>
}

View file

@ -5,8 +5,10 @@ import com.tangem.feature.swap.domain.AllowPermissionsHandler
import com.tangem.feature.swap.domain.AllowPermissionsHandlerImpl
import com.tangem.feature.swap.domain.GetSwapUiModeUseCase
import com.tangem.feature.swap.domain.SetSwapUiModeUseCase
import com.tangem.feature.swap.domain.SwapFeedbackUseCase
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.SwapInteractorImpl
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.feature.swap.domain.transfer.SwapTransferInteractor
@ -44,6 +46,12 @@ internal class SwapDomainModule {
@Singleton
fun provideSetSwapUiModeUseCase(swapRepository: SwapRepository): SetSwapUiModeUseCase =
SetSwapUiModeUseCase(swapRepository = swapRepository)
@Provides
@Singleton
fun provideSwapFeedbackUseCase(repository: SwapFeedbackRepository): SwapFeedbackUseCase {
return SwapFeedbackUseCase(repository)
}
}
@Module

View file

@ -0,0 +1,3 @@
package com.tangem.feature.swap.domain.models.domain
data class ExistingRating(val rating: Int)

View file

@ -0,0 +1,10 @@
package com.tangem.feature.swap.domain.models.domain
data class SwapFeedbackParams(
val userWalletIdHash: String,
val providerName: String,
val txUrl: String,
val txExternalId: String,
val rating: Int,
val feedback: String,
)

View file

@ -0,0 +1,63 @@
package com.tangem.feature.swap.domain
import arrow.core.left
import arrow.core.right
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
import com.tangem.feature.swap.domain.models.domain.ExistingRating
import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams
import com.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class SwapFeedbackUseCaseTest {
private val repository: SwapFeedbackRepository = mockk()
private val useCase = SwapFeedbackUseCase(repository)
@Test
fun `getExistingRating returns ExistingRating when rated`() = runTest {
coEvery { repository.getRating("tx123") } returns ExistingRating(rating = 4).right()
val result = useCase.getExistingRating("tx123")
assertThat(result.getOrNull()).isEqualTo(ExistingRating(rating = 4))
}
@Test
fun `getExistingRating returns null when not rated`() = runTest {
coEvery { repository.getRating("tx123") } returns null.right()
val result = useCase.getExistingRating("tx123")
assertThat(result.getOrNull()).isNull()
}
@Test
fun `getExistingRating returns Left on error`() = runTest {
coEvery { repository.getRating("tx123") } returns RuntimeException("Network error").left()
val result = useCase.getExistingRating("tx123")
assertThat(result.isLeft()).isTrue()
}
@Test
fun `submit delegates to repository`() = runTest {
val params = SwapFeedbackParams(
userWalletIdHash = "hash",
providerName = "ChangeNOW",
txUrl = "https://example.com/tx/abc",
txExternalId = "tx123",
rating = 5,
feedback = "Great!",
)
coEvery { repository.submitFeedback(params) } returns Unit.right()
useCase.submit(params)
coVerify(exactly = 1) { repository.submitFeedback(params) }
}
}

View file

@ -24,4 +24,8 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
override val isSwapProviderFilterEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15009_SWAP_PROVIDER_FILTER_ENABLED,
)
override val isSwapRateExperienceEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15103_SWAP_RATE_EXPERIENCE_ENABLED,
)
}

View file

@ -168,7 +168,7 @@ internal fun TangemPayDetailsScreen(
}
}
}
expressTransactionsBottomSheetState?.content()
expressTransactionsBottomSheetState?.content(null)
}
}

View file

@ -26,6 +26,10 @@ interface ExpressTransactionsComponent {
data class Params(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val onRatingRequested: (
(txExternalId: String, providerName: String, txExternalUrl: String, userWalletIdStringValue: String) -> Unit
)? = null,
val onRatingDismiss: (() -> Unit)? = null,
)
interface Factory : ComponentFactory<Params, ExpressTransactionsComponent>

View file

@ -58,6 +58,7 @@ dependencies {
implementation(projects.core.configToggles)
implementation(projects.core.decompose)
implementation(projects.common.ui)
implementation(projects.features.rating.api)
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)

View file

@ -26,6 +26,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.TransferBottomSheetComponent
import com.tangem.features.rating.RatingComponent
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.tokendetails.ExpressTransactionsComponent
import com.tangem.features.tokendetails.TokenDetailsComponent
@ -47,6 +48,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
private val yieldSupplyWarningComponentFactory: YieldSupplyDepositedWarningComponent.Factory,
yieldSupplyComponentFactory: YieldSupplyComponent.Factory,
private val ratingComponentFactory: RatingComponent.Factory,
) : TokenDetailsComponent, AppComponentContext by appComponentContext {
private val model: TokenDetailsModel = getOrCreateModel(params)
@ -64,6 +66,8 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
params = ExpressTransactionsComponent.Params(
userWalletId = params.userWalletId,
currency = params.currency,
onRatingRequested = model::activateRatingForExpressTx,
onRatingDismiss = { model.ratingSlotNavigation.dismiss() },
),
)
@ -74,6 +78,15 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
childFactory = ::bottomSheetChild,
)
private val ratingSlot = childSlot(
key = RATING_SLOT_KEY,
source = model.ratingSlotNavigation,
serializer = null,
childFactory = { params, ctx ->
ratingComponentFactory.create(childByContext(ctx), params)
},
)
private val tokenMarketBlockComponent = params.currency.toTokenMarketParam()?.let { tokenMarketParams ->
tokenMarketBlockComponentFactory.create(
appComponentContext = child("tokenMarketBlockComponent"),
@ -94,11 +107,13 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val bottomSheet by bottomSheetSlot.subscribeAsState()
val ratingSlotState by ratingSlot.subscribeAsState()
NavigationBar3ButtonsScrim()
if (LocalRedesignEnabled.current) {
val tokenDetailsUM by model.redesignUiState.collectAsStateWithLifecycle()
// TODO [REDACTED_TASK_KEY]: wire ratingSlotState into TokenDetailsScreen when redesign is ready
TokenDetailsScreen(
tokenDetailsUM = tokenDetailsUM,
tokenMarketBlockComponent = tokenMarketBlockComponent,
@ -115,6 +130,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
txHistoryComponent = txHistoryComponent,
yieldSupplyComponent = yieldSupplyComponent,
expressTransactionsComponent = expressTransactionsComponent,
ratingComponent = ratingSlotState.child?.instance,
)
}
@ -178,4 +194,8 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
params: TokenDetailsComponent.Params,
): DefaultTokenDetailsComponent
}
companion object {
private const val RATING_SLOT_KEY = "ratingSlot"
}
}

View file

@ -27,6 +27,7 @@ import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.ExpressStateFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express.ExpressStatusFactory
import com.tangem.features.tokendetails.ExpressTransactionsComponent
import com.tangem.features.tokendetails.ExpressTransactionsEvent
@ -111,6 +112,16 @@ internal class ExpressTransactionsModel @Inject constructor(
val expressTxState = internalUiState.value.transactionsToDisplay.firstOrNull { it.info.txId == txId }
?: return
internalUiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState)
if (expressTxState is ExchangeUM) {
expressTxState.info.txExternalId?.let { txExternalId ->
params.onRatingRequested?.invoke(
txExternalId,
expressTxState.provider.name,
expressTxState.info.txExternalUrl.orEmpty(),
expressTxState.fromUserWalletId.stringValue,
)
}
}
}
override fun onGoToProviderClick(url: String) {
@ -159,6 +170,7 @@ internal class ExpressTransactionsModel @Inject constructor(
)
}
}
params.onRatingDismiss?.invoke()
internalUiState.value = stateFactory.getStateWithClosedBottomSheet()
}
@ -170,6 +182,7 @@ internal class ExpressTransactionsModel @Inject constructor(
}
}
}
params.onRatingDismiss?.invoke()
internalUiState.value = stateFactory.getStateWithClosedBottomSheet()
}

View file

@ -10,11 +10,18 @@ import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.address.AddressType
import com.tangem.domain.dynamicaddresses.IsDynamicAddressesAvailableUseCase
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.bottomsheet.receive.AddressModel
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.features.rating.RatingComponent
import com.tangem.feature.swap.domain.SwapFeedbackUseCase
import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
@ -183,6 +190,8 @@ internal class TokenDetailsModel @Inject constructor(
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val designFeatureToggles: DesignFeatureToggles,
private val redesignStateController: TokenDetailsStateController,
private val swapFeedbackUseCase: SwapFeedbackUseCase,
private val swapFeatureToggles: SwapFeatureToggles,
) : Model(),
TokenDetailsClickIntents,
YieldSupplyDepositedWarningComponent.ModelCallback {
@ -209,6 +218,7 @@ internal class TokenDetailsModel @Inject constructor(
private var isBalanceLoadedEventSent = false
val bottomSheetNavigation: SlotNavigation<TokenDetailsBottomSheetConfig> = SlotNavigation()
val ratingSlotNavigation = SlotNavigation<RatingComponent.Params>()
private val stateFactory = TokenDetailsStateFactory(
currentStateProvider = Provider { uiState.value },
@ -907,6 +917,7 @@ internal class TokenDetailsModel @Inject constructor(
state.copy(pullToRefreshConfig = state.pullToRefreshConfig.copy(isRefreshing = false))
}
}.saveIn(refreshStateJobHolder)
ratingSlotNavigation.dismiss()
}
override fun onCloseRentInfoNotification() {
@ -1079,6 +1090,37 @@ internal class TokenDetailsModel @Inject constructor(
uiState.value = stateFactory.getStateWithUpdatedBalanceSegmentedButtonConfig(config)
}
fun activateRatingForExpressTx(
txExternalId: String,
providerName: String,
txExternalUrl: String,
userWalletIdStringValue: String,
) {
if (!swapFeatureToggles.isSwapRateExperienceEnabled) return
ratingSlotNavigation.activate(
RatingComponent.Params(
onLoadRating = {
swapFeedbackUseCase.getExistingRating(txExternalId)
.fold(ifLeft = { null }, ifRight = { it?.rating })
},
onSubmitRating = { rating, feedback ->
swapFeedbackUseCase.submit(
SwapFeedbackParams(
userWalletIdHash = userWalletIdStringValue.hexToBytes()
.calculateSha256()
.toHexString(),
providerName = providerName,
txUrl = txExternalUrl,
txExternalId = txExternalId,
rating = rating,
feedback = feedback,
),
).onLeft { TangemLogger.e("Failed to submit swap feedback: $it") }
},
),
)
}
override fun onYieldInfoClick() {
analyticsEventsHandler.send(
YieldSupplyAnalytics.EarnedFundsInfo(

View file

@ -207,9 +207,12 @@ internal class ExpressStatusFactory @AssistedInject constructor(
}
private fun TangemBottomSheetConfig.toBottomSheetSlot(): BottomSheetSlot {
val contentLambda: @Composable () -> Unit = {
val contentLambda: @Composable ((@Composable () -> Unit)?) -> Unit = { extraContent ->
when (this.content) {
is ExpressStatusBottomSheetConfig -> ExpressStatusBottomSheet(config = this)
is ExpressStatusBottomSheetConfig -> ExpressStatusBottomSheet(
config = this,
extraContent = extraContent,
)
}
}
return BottomSheetSlot(config = this, content = contentLambda)

View file

@ -126,7 +126,7 @@ internal fun TokenDetailsScreen(
)
}
expressState.bottomSheetSlot?.content()
expressState.bottomSheetSlot?.content(null)
}
}

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameter
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState
import com.tangem.features.rating.RatingComponent
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer
import com.tangem.core.ui.components.marketprice.MarketPriceBlock
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
@ -43,7 +44,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
// TODO: Split to blocks [REDACTED_JIRA]
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod", "LongParameterList")
@Composable
internal fun TokenDetailsScreenLegacy(
state: TokenDetailsState,
@ -51,6 +52,7 @@ internal fun TokenDetailsScreenLegacy(
txHistoryComponent: TxHistoryComponent,
yieldSupplyComponent: YieldSupplyComponent,
expressTransactionsComponent: ExpressTransactionsComponent,
ratingComponent: RatingComponent?,
) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
@ -164,7 +166,9 @@ internal fun TokenDetailsScreenLegacy(
}
}
expressState.bottomSheetSlot?.content()
expressState.bottomSheetSlot?.content(
ratingComponent?.let { comp -> { comp.Content(modifier = Modifier.fillMaxWidth()) } },
)
}
}
@ -198,6 +202,7 @@ private fun TokenDetailsScreenPreview(
}
},
expressTransactionsComponent = PreviewExpressTransactionsComponent,
ratingComponent = null,
)
}
}

View file

@ -15,14 +15,17 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.E
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express.exchange.ExchangeStatusBottomSheetContent
@Composable
internal fun ExpressStatusBottomSheet(config: TangemBottomSheetConfig) {
internal fun ExpressStatusBottomSheet(
config: TangemBottomSheetConfig,
extraContent: (@Composable () -> Unit)? = null,
) {
TangemBottomSheet(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
) { content: ExpressStatusBottomSheetConfig ->
when (val state = content.value) {
is ExpressTransactionStateUM.OnrampUM -> OnrampStatusBottomSheetContent(state)
is ExchangeUM -> ExchangeStatusBottomSheetContent(state)
is ExchangeUM -> ExchangeStatusBottomSheetContent(state = state, extraContent = extraContent)
}
}
}

View file

@ -28,7 +28,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.component
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
@Composable
internal fun ExchangeStatusBottomSheetContent(state: ExchangeUM) {
internal fun ExchangeStatusBottomSheetContent(state: ExchangeUM, extraContent: (@Composable () -> Unit)? = null) {
Column(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
@ -70,6 +70,10 @@ internal fun ExchangeStatusBottomSheetContent(state: ExchangeUM) {
imageUrl = state.provider.imageLarge,
)
SpacerH12()
if (extraContent != null) {
extraContent()
SpacerH12()
}
ExchangeStatusBlock(
statuses = state.statuses,
showLink = state.showProviderLink,