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

@ -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,
)
}