From 8d0f11d10bd44f1c0cd9fd7484ca620981bf4df5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 22 May 2026 11:17:28 +0200 Subject: [PATCH] Updated on 2026-08-14 --- app/build.gradle.kts | 1 + app/src/main/assets/tangem-app-config | 2 +- .../state/ExpressTransactionsBlockState.kt | 2 +- .../configs/feature_toggles_config.json | 4 + .../datasource/api/common/config/ApiConfig.kt | 2 + .../api/common/config/SurveySparrow.kt | 26 +++ .../api/surveysparrow/SurveySparrowApi.kt | 21 +++ .../models/CreateSurveySparrowResponseBody.kt | 11 ++ .../models/SurveySparrowAnswerDto.kt | 10 ++ .../models/SurveySparrowGetAnswerDto.kt | 12 ++ .../models/SurveySparrowResponseDto.kt | 9 + .../models/SurveySparrowResponsesDto.kt | 9 + .../tangem/datasource/di/ApiConfigsModule.kt | 6 + .../com/tangem/datasource/di/NetworkModule.kt | 10 ++ .../config/environment/EnvironmentConfig.kt | 2 + .../GeneratedEnvironmentConfigConverter.kt | 13 ++ .../models/EnvironmentConfigModels.kt | 8 +- .../api/common/config/ApiConfigTest.kt | 1 + .../managers/ProdApiConfigsManagerTest.kt | 17 ++ core/res/src/main/res/values-de/strings.xml | 56 +++++- core/res/src/main/res/values-es/strings.xml | 22 ++- core/res/src/main/res/values-fr/strings.xml | 22 ++- core/res/src/main/res/values-it/strings.xml | 6 +- core/res/src/main/res/values-ja/strings.xml | 59 ++++++- .../src/main/res/values-pt-rBR/strings.xml | 53 +++++- core/res/src/main/res/values-ru/strings.xml | 22 ++- .../src/main/res/values-uk-rUA/strings.xml | 22 ++- .../src/main/res/values-zh-rCN/strings.xml | 53 +++++- .../src/main/res/values-zh-rTW/strings.xml | 6 +- core/res/src/main/res/values/strings.xml | 29 +++- .../main/res/drawable/ic_rating_star_24.xml | 20 +++ features/rating/api/build.gradle.kts | 14 ++ .../tangem/features/rating/RatingComponent.kt | 16 ++ features/rating/impl/build.gradle.kts | 37 ++++ .../feature/rating/DefaultRatingComponent.kt | 33 ++++ .../tangem/feature/rating/di/RatingModule.kt | 33 ++++ .../feature/rating/model/RatingModel.kt | 137 +++++++++++++++ .../tangem/feature/rating/ui/RatingBlock.kt | 103 ++++++++++++ .../feature/rating/ui/RatingFeedbackBS.kt | 11 ++ .../rating/ui/RatingFeedbackBottomSheet.kt | 159 ++++++++++++++++++ .../com/tangem/feature/rating/ui/RatingUM.kt | 15 ++ .../feature/rating/model/RatingModelTest.kt | 137 +++++++++++++++ .../features/swap/SwapFeatureToggles.kt | 1 + features/swap/data/build.gradle.kts | 1 + .../swap/DefaultSwapFeedbackRepository.kt | 69 ++++++++ .../swap/NoOpSwapFeedbackRepository.kt | 14 ++ .../tangem/feature/swap/di/SwapDataModule.kt | 21 +++ .../swap/domain/SwapFeedbackUseCase.kt | 16 ++ .../swap/domain/api/SwapFeedbackRepository.kt | 10 ++ .../swap/domain/di/SwapDomainModule.kt | 8 + .../domain/models/domain/ExistingRating.kt | 3 + .../models/domain/SwapFeedbackParams.kt | 10 ++ .../swap/domain/SwapFeedbackUseCaseTest.kt | 63 +++++++ .../feature/swap/DefaultSwapFeatureToggles.kt | 4 + .../tangempay/ui/TangemPayDetailsScreen.kt | 2 +- .../ExpressTransactionsComponent.kt | 4 + features/tokendetails/impl/build.gradle.kts | 1 + .../DefaultTokenDetailsComponent.kt | 20 +++ .../model/ExpressTransactionsModel.kt | 13 ++ .../tokendetails/model/TokenDetailsModel.kt | 42 +++++ .../factory/express/ExpressStatusFactory.kt | 7 +- .../tokendetails/ui/TokenDetailsScreen.kt | 2 +- .../ui/TokenDetailsScreenLegacy.kt | 9 +- .../express/ExpressStatusBottomSheet.kt | 7 +- .../ExchangeStatusBottomSheetContent.kt | 6 +- .../extension/BaseExtensionConfigurations.kt | 1 + settings.gradle.kts | 3 + 67 files changed, 1527 insertions(+), 41 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/common/config/SurveySparrow.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/SurveySparrowApi.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/CreateSurveySparrowResponseBody.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowAnswerDto.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowGetAnswerDto.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowResponseDto.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowResponsesDto.kt create mode 100644 core/ui/src/main/res/drawable/ic_rating_star_24.xml create mode 100644 features/rating/api/build.gradle.kts create mode 100644 features/rating/api/src/main/java/com/tangem/features/rating/RatingComponent.kt create mode 100644 features/rating/impl/build.gradle.kts create mode 100644 features/rating/impl/src/main/java/com/tangem/feature/rating/DefaultRatingComponent.kt create mode 100644 features/rating/impl/src/main/java/com/tangem/feature/rating/di/RatingModule.kt create mode 100644 features/rating/impl/src/main/java/com/tangem/feature/rating/model/RatingModel.kt create mode 100644 features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingBlock.kt create mode 100644 features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingFeedbackBS.kt create mode 100644 features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingFeedbackBottomSheet.kt create mode 100644 features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingUM.kt create mode 100644 features/rating/impl/src/test/java/com/tangem/feature/rating/model/RatingModelTest.kt create mode 100644 features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapFeedbackRepository.kt create mode 100644 features/swap/data/src/main/java/com/tangem/feature/swap/NoOpSwapFeedbackRepository.kt create mode 100644 features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapFeedbackUseCase.kt create mode 100644 features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapFeedbackRepository.kt create mode 100644 features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExistingRating.kt create mode 100644 features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeedbackParams.kt create mode 100644 features/swap/domain/src/test/java/com/tangem/feature/swap/domain/SwapFeedbackUseCaseTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 26e17d1dc3..22f93e0530 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -236,6 +236,7 @@ dependencies { implementation(projects.common.ui) /** Features */ + implementation(projects.features.rating.impl) implementation(projects.features.referral.impl) implementation(projects.features.referral.domain) implementation(projects.features.referral.data) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 158fbd8808..12821d37a8 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 158fbd8808d2db92ef82d3f9ed92c81340c707c5 +Subproject commit 12821d37a835b5a225c69912a37df33506b315bf diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionsBlockState.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionsBlockState.kt index 1d223a1ead..22814c356e 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionsBlockState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionsBlockState.kt @@ -12,5 +12,5 @@ data class ExpressTransactionsBlockState( data class BottomSheetSlot( val config: TangemBottomSheetConfig, - val content: @Composable () -> Unit, + val content: @Composable (extraContent: (@Composable () -> Unit)?) -> Unit, ) \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 4adb3415e8..4c88446e0a 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -86,5 +86,9 @@ { "name": "AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING", "version": "undefined" + }, + { + "name": "AND_15103_SWAP_RATE_EXPERIENCE_ENABLED", + "version": "undefined" } ] diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index 515a93b2f8..c4f1f54238 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -32,6 +32,7 @@ sealed class ApiConfig { MoonPay, News, GaslessTxService, + SurveySparrow, } private fun initializeId(): ID { @@ -47,6 +48,7 @@ sealed class ApiConfig { is MoonPay -> ID.MoonPay is News -> ID.News is GaslessTxService -> ID.GaslessTxService + is SurveySparrow -> ID.SurveySparrow } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/SurveySparrow.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/SurveySparrow.kt new file mode 100644 index 0000000000..2b133c812a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/SurveySparrow.kt @@ -0,0 +1,26 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.local.config.environment.EnvironmentConfig +import com.tangem.utils.ProviderSuspend + +internal class SurveySparrow( + private val environmentConfig: EnvironmentConfig, +) : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD + + override val environmentConfigs = listOf( + createProdEnvironment(), + ) + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://eu-api.surveysparrow.com/", + headers = buildMap { + put( + key = "Authorization", + value = ProviderSuspend { "Bearer ${environmentConfig.surveySparrowToken.orEmpty()}" }, + ) + }, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/SurveySparrowApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/SurveySparrowApi.kt new file mode 100644 index 0000000000..97f376cc86 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/SurveySparrowApi.kt @@ -0,0 +1,21 @@ +package com.tangem.datasource.api.surveysparrow + +import com.tangem.datasource.api.surveysparrow.models.CreateSurveySparrowResponseBody +import com.tangem.datasource.api.surveysparrow.models.SurveySparrowResponsesDto +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +interface SurveySparrowApi { + + @GET("v3/responses") + suspend fun getResponses( + @Query("survey_id") surveyId: Long, + @Query("variables") variables: String, + @Query("limit") limit: Int = 1, + ): SurveySparrowResponsesDto + + @POST("v3/responses") + suspend fun createResponse(@Body body: CreateSurveySparrowResponseBody) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/CreateSurveySparrowResponseBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/CreateSurveySparrowResponseBody.kt new file mode 100644 index 0000000000..426a54b35b --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/CreateSurveySparrowResponseBody.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.surveysparrow.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CreateSurveySparrowResponseBody( + @Json(name = "survey_id") val surveyId: Long, + @Json(name = "answers") val answers: List, + @Json(name = "variables") val variables: Map, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowAnswerDto.kt b/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowAnswerDto.kt new file mode 100644 index 0000000000..209be8855c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowAnswerDto.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.surveysparrow.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class SurveySparrowAnswerDto( + @Json(name = "question_id") val questionId: Long, + @Json(name = "answer") val answer: String? = null, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowGetAnswerDto.kt b/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowGetAnswerDto.kt new file mode 100644 index 0000000000..97b0d8ac39 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowGetAnswerDto.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.api.surveysparrow.models + +import com.squareup.moshi.Json + +// No @JsonClass: KotlinJsonAdapterFactory (registered in MoshiModule) handles this via reflection. +// Any? is required because the API returns question_id as Long for survey questions but as String +// ("startTime", "submittedTime", etc.) for metadata answers, and answer as Int for ratings but +// as String for other answer types. +data class SurveySparrowGetAnswerDto( + @Json(name = "question_id") val questionId: Any?, + @Json(name = "answer") val answer: Any?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowResponseDto.kt b/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowResponseDto.kt new file mode 100644 index 0000000000..a5f028cde7 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowResponseDto.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.surveysparrow.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class SurveySparrowResponseDto( + @Json(name = "answers") val answers: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowResponsesDto.kt b/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowResponsesDto.kt new file mode 100644 index 0000000000..a8e34ee2f1 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/surveysparrow/models/SurveySparrowResponsesDto.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.surveysparrow.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class SurveySparrowResponsesDto( + @Json(name = "data") val data: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index e341d9724b..d6e55b6589 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -107,4 +107,10 @@ internal object ApiConfigsModule { appInfoProvider = appInfoProvider, ) } + + @Provides + @IntoSet + fun provideSurveySparrowConfig(environmentConfig: EnvironmentConfig): ApiConfig { + return SurveySparrow(environmentConfig) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index f9bbca2fd3..06bcabc816 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -2,6 +2,7 @@ package com.tangem.datasource.di import com.tangem.datasource.BuildConfig import com.tangem.datasource.api.common.blockaid.BlockAidApi +import com.tangem.datasource.api.surveysparrow.SurveySparrowApi import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE import com.tangem.datasource.api.common.config.ApiConfigs @@ -180,6 +181,15 @@ internal object NetworkModule { ) } + @Provides + @Singleton + fun provideSurveySparrowApi(retrofitApiBuilder: RetrofitApiBuilder): SurveySparrowApi { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.SurveySparrow, + applyTimeoutAnnotations = false, + ) + } + @Provides @Singleton fun provideMoonPayApi(retrofitApiBuilder: RetrofitApiBuilder): MoonPayApi { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt index c148966eba..024f363f10 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt @@ -3,6 +3,7 @@ package com.tangem.datasource.local.config.environment import com.tangem.blockchain.common.BlockchainSdkConfig import com.tangem.datasource.local.config.environment.models.ExpressModel import com.tangem.datasource.local.config.environment.models.P2PKeys +import com.tangem.datasource.local.config.environment.models.SurveySparrowSwapRatingConfig data class EnvironmentConfig( val moonPayApiKey: String = "", @@ -31,4 +32,5 @@ data class EnvironmentConfig( val gaslessTxApiKey: String? = null, val customerIoCdpApiKey: String? = null, val surveySparrowToken: String? = null, + val surveySparrowSwapRating: SurveySparrowSwapRatingConfig? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt index f1f941c2b4..fa4a318031 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt @@ -11,6 +11,7 @@ import com.tangem.datasource.local.config.environment.generated.GeneratedEnviron import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig.TonCenterApiKey import com.tangem.datasource.local.config.environment.models.ExpressModel import com.tangem.datasource.local.config.environment.models.P2PKeys +import com.tangem.datasource.local.config.environment.models.SurveySparrowSwapRatingConfig /** * Converts [GeneratedEnvironmentConfig] to [EnvironmentConfig] @@ -54,6 +55,7 @@ internal object GeneratedEnvironmentConfigConverter { gaslessTxApiKey = GeneratedEnvironmentConfig.gaslessTxApiKey, customerIoCdpApiKey = GeneratedEnvironmentConfig.CustomerIO.androidApiKey, surveySparrowToken = GeneratedEnvironmentConfig.SurveySparrow.apiKey, + surveySparrowSwapRating = createSurveySparrowSwapRating(), ) } @@ -181,4 +183,15 @@ internal object GeneratedEnvironmentConfigConverter { stellar = GetBlockAccessToken(rest = GetBlockAccessTokens.Stellar.rest), ) } + + private fun createSurveySparrowSwapRating(): SurveySparrowSwapRatingConfig? { + val surveyId = GeneratedEnvironmentConfig.SurveySparrow.SwapRating.surveyId.toLongOrNull() + val ratingQuestionId = GeneratedEnvironmentConfig.SurveySparrow.SwapRating.ratingQuestionId.toLongOrNull() + val feedbackQuestionId = GeneratedEnvironmentConfig.SurveySparrow.SwapRating.feedbackQuestionId.toLongOrNull() + return if (surveyId != null && ratingQuestionId != null && feedbackQuestionId != null) { + SurveySparrowSwapRatingConfig(surveyId, ratingQuestionId, feedbackQuestionId) + } else { + null + } + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/models/EnvironmentConfigModels.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/models/EnvironmentConfigModels.kt index b3dcc73c77..6b823a997f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/models/EnvironmentConfigModels.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/models/EnvironmentConfigModels.kt @@ -2,4 +2,10 @@ package com.tangem.datasource.local.config.environment.models data class ExpressModel(val apiKey: String, val signVerifierPublicKey: String) -data class P2PKeys(val mainnet: String, val hoodi: String) \ No newline at end of file +data class P2PKeys(val mainnet: String, val hoodi: String) + +data class SurveySparrowSwapRatingConfig( + val surveyId: Long, + val ratingQuestionId: Long, + val feedbackQuestionId: Long, +) \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt index 2e820ceaac..1b886a0447 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt @@ -87,6 +87,7 @@ class ApiConfigTest { authProvider = appAuthProvider, appInfoProvider = mockk(), ) + ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig) } } } diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index 102cfd0da8..2dca2bbfaf 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -129,6 +129,7 @@ internal class ProdApiConfigsManagerTest { authProvider = appAuthProvider, appInfoProvider = appInfoProvider, ) + ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig) } } } @@ -146,6 +147,7 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.P2PEthPool -> createP2PModel() ApiConfig.ID.News -> createNewsModel() ApiConfig.ID.GaslessTxService -> createGaslessTxServiceModel() + ApiConfig.ID.SurveySparrow -> createSurveySparrowModel() } } @@ -320,6 +322,19 @@ internal class ProdApiConfigsManagerTest { ) } + private fun createSurveySparrowModel(): TestModel { + return TestModel( + id = ApiConfig.ID.SurveySparrow, + expected = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://eu-api.surveysparrow.com/", + headers = mapOf( + "Authorization" to ProviderSuspend { "Bearer $SURVEY_SPARROW_API_KEY" }, + ), + ), + ) + } + private fun createBlockAidSdkModel(): TestModel { return TestModel( id = ApiConfig.ID.BlockAid, @@ -425,6 +440,7 @@ internal class ProdApiConfigsManagerTest { const val TANGEM_GASLESS_API_KEY = "tangem_gasless_api_key" const val TANGEM_PAY_BFF_KEY_DEV = "tangem_pay_bff_key_dev" const val BLOCK_AID_API_KEY = "block_aid_api_key" + const val SURVEY_SPARROW_API_KEY = "survey_sparrow_api_key" const val EXPRESS_API_KEY = "express_api_key" const val EXPRESS_DEV_API_KEY = "express_dev_api_key" const val YIELD_MODULE_KEY = "yield_module_key" @@ -460,6 +476,7 @@ internal class ProdApiConfigsManagerTest { bffStaticTokenDev = TANGEM_PAY_BFF_KEY_DEV, gaslessTxApiKeyDev = TANGEM_GASLESS_API_KEY, gaslessTxApiKey = TANGEM_GASLESS_API_KEY, + surveySparrowToken = SURVEY_SPARROW_API_KEY, ) } } diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 786150a81b..0d414b7743 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -369,6 +369,7 @@ Jetzt OK Im Browser öffnen + Einstellungen öffnen oder Hauptkarte Primärring @@ -1071,7 +1072,7 @@ Andere Optionen Deine Schlüssel(private-keys) werden sicher im Inneren der Karte oder Ring generiert. Es gibt keine Seed-Phrase, d. h. niemand kann sie exportieren oder stehlen. Schlüssel anonym generieren - Indem Sie fortfahren, stimmen Sie den folgenden Bedingungen zu: + Indem Sie fortfahren, stimmen Sie den folgenden Bedingungen zu:%s Deine Karte oder Ring ist aktiviert und einsatzbereit Erfolgreich! Deine Wallet ist eingerichtet und einsatzbereit! @@ -1161,12 +1162,17 @@ Der Service wird von einem externen Anbieter bereitgestellt. \n Tangem übernimmt keine Verantwortung. Der Kaufbetrag sollte nicht höher sein als %s Der zu kaufende Betrag muss mindestens %s betragen + Kumulierte Transaktionsbeträge über %1s können eine Identitätsüberprüfung mit %2s + Kumulierte Transaktionsbeträge über dem Gegenwert von %1s können eine Identitätsprüfung mit %2s + Indem du auf \"Bezahlen\" klicken, stimmen Sie %1s\'s %2s und %3szu. Keine verfügbaren Anbieter für diese Währung Schnellste Bearbeitung Bezahlen mit Zahlungsmethode Verfügbar bis zu %s Erhältlich bei %s + In den USA und Großbritannien ausgestellte Karten können nicht über diese Methode abgewickelt werden. Der Anbieter kann eine zusätzliche Identitätsprüfung verlangen + Anforderungen an die Anbieter Anbieter Anbieter @@ -1203,6 +1209,15 @@ Token organisieren Gruppe löschen %s Unterstützung + Push-Benachrichtigungen sind aktiviert, funktionieren aber erst, nachdem du Benachrichtigungen in den Geräteeinstellungen zugelassen hast. + Benachrichtigungen zulassen + Produktneuheiten, exklusive Angebote und Erinnerungen an Aktivitäten. + Angebote & Updates + Lasse dich über Preisänderungen der wichtigsten Kryptowährungen benachrichtigen. + Preisalarm + Benachrichtigungseinstellungen + Echtzeit-Warnungen für Transaktionen, Umtausch und wichtige Aktualisierungen. + Transaktionsstatus Mehr Infos Du kannst Benachrichtigungen für Tangem in den Einstellungen aktivieren. Später aktivieren @@ -1633,6 +1648,10 @@ Unzureichende Mittel Nicht genügend Geldmittel, um diese Transaktion abzuschließen. Verringern Sie den zu erhaltenden Betrag oder fügen Sie weitere Mittel hinzu. Erlaubnis erteilen + Bewerten deine Erfahrung mit dem Anbieter + Geben dein Feedback ein + Feedback senden + Was waren deine Erfahrungen? Tauschen Tauschen... Zu erhaltender Betrag @@ -1648,6 +1667,10 @@ Karte kann nicht umbenannt werden Karte eingefroren Kartenzahlung + Es wird vom Zahlungskonto verschwinden + Karte schließen + Geh zurück + Ihre Karte schließen? Einzahlung Streitfall Transaktion erkunden @@ -1735,6 +1758,8 @@ Kartenname Limit festlegen ab %s Das Limit konnte nicht festgelegt werden. Bitte versuchen Sie es erneut. + Dauert in der Regel bis zu 5 Minuten + Schließen Ihrer Karte Ändern Aktuelles Limit Ihr Tageslimit konnte nicht geladen werden. Bitte versuchen Sie es erneut. @@ -1743,6 +1768,10 @@ Tageslimit ist festgelegt Tageslimit Einstellungen der Karte + + Karte + Karten + PIN-Code ändern Kehren Sie zur App zurück, falls Sie ihn vergessen. Limit von %s bis %s festlegen @@ -1798,7 +1827,7 @@ Erhalten Sie Ihre kostenlose Tangem Pay Card in wenigen Minuten Bezahlen mit Zahlungskonto - Zahlungskonto ist nicht synchronisiert + Tangem Pay sitzung abgelaufen Ungültige PIN: Sequenzen oder Wiederholungen vermeiden Karte neu ausstellen Dadurch wird ein neuer Kartendatensatz erstellt. Ihre alten Daten funktionieren nicht mehr. Dieser Vorgang kann nicht rückgängig gemacht werden. @@ -1816,8 +1845,11 @@ Satz \nPIN-Code Karte deaktiviert Ersetzen deine Karte - Sitzung abgelaufen + Karte oder Ring verwenden, um die Sitzung zu verlängern + Karte oder Ring verwenden, um die Sitzung zu verlängern + Zugang wiederherstellen Zugang wiederherstellen + Tangem Pay sitzung abgelaufen Nutzen Sie USDC für alltägliche Zahlungen Tangem Pay ist vorübergehend nicht erreichbar. Tangem Pay @@ -1846,6 +1878,7 @@ Verfügbares Guthaben Gesamtsaldo Bis zu %s effektiver Jahreszins + Bis zu %s APY Generiere XPUB Ausblenden Du bist dabei, dieses Token vom Hauptbildschirm auszublenden. Du kannst es jederzeit über die Seite „Token verwalten“ wieder hinzufügen. @@ -2180,6 +2213,8 @@ Fehlende Sicherung Diese Karte oder Ring wurde bereits für Transaktionen verwendet. Wenn die Karte oder Ring aus einer nicht vertrauenswürdigen Quelle stammt, solltest du den gesamten Betrag abheben. Wenn es sich um deine Karte oder Ring handelt, sind keine Maßnahmen erforderlich. Karte oder Ring hat bereits Transaktionen unterzeichnet + Wird so schnell wie möglich aktualisiert. + Es fehlen einige Token-Guthaben. Deine Bewertung motiviert uns, die Tangem Wallet noch besser zu machen. Gefällt dir Tangem? Du musst deinen Token zuordnen, bevor du Token erhalten kannst @@ -2329,6 +2364,20 @@ Nein, alles senden Um %s XTZ reduziert Damit Sie beim nächsten Aufladen Ihrer Brieftasche keine erhöhte Provision zahlen, soll der Betrag um %s XTZ reduziert werden + Erkundung des Ertragsmodus + Bonus bei erstmaliger Aktivierung! + Sonderangebot für den Yield-Modus + APY x3 + yield_apy_boost_block_activate + Du hast Anspruch auf einen 30-tägigen APY-Boost, es gelten die T&C, erfahren Sie mehr + Aktiviere den Renditemodus zum ersten Mal und erhalte in den ersten 30 Tagen bis zu 3x Rendite + Bonus für den ersten Monat APR + Sie erhalten Marktrendite + Bonus. Der Bonus wird einmalig in USDT oder USDC innerhalb von 14 Tagen nach Ablauf der 30-Tage-Frist ausgezahlt. Verfügbar, solange das Promo-Budget reicht. Bedingungen und Konditionen gelten. + Zusammenfassung + Lass dein Guthaben 30 Tage lang im Renditemodus. Der Bonus basiert auf der Rendite, die du in diesem Zeitraum tatsächlich erzielen. + Wie du dich qualifiziert + 3 × Marktrendite für die ersten 30 Tage\nMindestanspruch: $1 der in 30 Tagen angesammelten Marktrendite\nMaximalbonus: $50 + Wie viel du bekommst Wenn der Yield-Modus aktiviert ist, gehen alle zukünftigen Einzahlungen an diese Adresse an Aave. Du kannst über Dein Guthaben weiterhin frei verfügen. Deine %s wird an Aave übermittelt Lieferung %1$s %2$s nach Aave @@ -2429,5 +2478,6 @@ Die Gebühr %s kann nicht gedeckt werden Der Yield-Modus ist momentan nicht verfügbar. Bitte versuche es später erneut. Yield Mode nicht verfügbar + Die Berechtigung zur Bonusauszahlung wird geprüft Chart konnte nicht geladen werden... diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index eaea4ea109..a05cfcde15 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -1510,6 +1510,7 @@ Al aprobar, permites que el contrato inteligente use tus tokens en futuras transacciones. Tasa Fija La red cobrará una tasa de aprobación del token para verificar que usted autoriza el uso de su token para el intercambio. + Intercambio en curso Intercambie más tokens a mejores tasas directamente en su billetera. ¡Nuevo proveedor de intercambio disponible! ¿Busca algo más?\n¡Intente buscar o explorar otra criptomoneda! @@ -1544,6 +1545,7 @@ Aprobar Error en la estimación de la tarifa. Envíe sus comentarios al servicio de asistencia. Usted intercambia + Usted envía Hacer un intercambio de esta cantidad del token seleccionado causará un impacto significativo en el precio y reducirá su resultado. Es posible que reciba una cantidad significativamente menor debido a la baja liquidez. Pruebe con una cantidad menor o con otro proveedor. Alto impacto en los precios @@ -1552,6 +1554,7 @@ Dar autorización Intercambiar Intercambiando... + Usted recibe Usted recibe Elige token no disponible @@ -1696,7 +1699,7 @@ Privacidad inigualable Obtén tu tarjeta Tangem Pay gratuita en minutos Cuenta de pago - La cuenta de pago no está sincronizada + Tangem Pay sesión expirada PIN no válido: evitar secuencias o repeticiones Reemitir tarjeta Esto generará un nuevo conjunto de datos de la tarjeta. Tus datos antiguos dejarán de funcionar. No podrás deshacer esta acción. @@ -1713,8 +1716,11 @@ No es posible mostrar los datos en este momento, pero los pagos con tarjeta siguen funcionando. Establecer \nCódigo PIN Tarjeta desactivada - Sesión expirada + Usa la tarjeta o el anillo para renovar la sesión + Usa la tarjeta o el anillo para renovar la sesión + Restablecer acceso Restablecer acceso + Tangem Pay sesión expirada Usa USDC para pagos cotidianos Tangem Pay no está disponible temporalmente. Tangem Pay @@ -2207,6 +2213,18 @@ No, enviar todo Reducir en %s XTZ Para evitar pagar una comisión mayor la próxima vez que recargue su billetera, reduzca el importe en %s XTZ + Explore el modo Rendimiento + ¡Bono por primera activación! + Oferta especial para el modo Rendimiento + APY x3 + Active el Modo Rendimiento por primera vez y obtenga hasta 3 veces más rendimiento durante sus primeros 30 días + Bonificación del primer mes APR + Usted obtiene rendimiento de mercado + Bonificación. La bonificación se paga una vez en USDT o USDC en un plazo de 14 días tras finalizar el periodo de 30 días. Disponible mientras dure el presupuesto promocional. Se aplican términos y condiciones + Resumen + Mantenga los fondos en modo Rendimiento durante 30 días consecutivos. La bonificación se basa en el rendimiento real obtenido durante ese periodo + Cómo calificar + 3 × rendimiento de mercado durante los 30 primeros días\nPosibilidad mínima: 1 $ de rendimiento de mercado acumulado durante 30 días\nBonificación máxima: 50 $ + Cuánto recibe Con el Modo Rendimiento activo, todos los depósitos futuros a esta dirección irán a Aave. Puede seguir gestionando sus fondos libremente. Su %s se suministra a Aave El suministro de %1$s %2$s a Aave está pendiente diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index e7e7f8b950..ff36e3ed4e 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1458,6 +1458,7 @@ En approuvant, vous autorisez le contrat intelligent à utiliser vos jetons dans de futures transactions. Taux fixe Le réseau facturera des frais d\'approbation de jeton pour vérifier que vous autorisez l\'utilisation de votre jeton pour l\'échange. + Échange en cours Échangez plus de jetons à de meilleurs taux directement dans votre portefeuille. Nouveau fournisseur d\'échange disponible ! Recherchez n’importe quel token, même s’il ne figure pas encore dans votre liste. @@ -1491,12 +1492,14 @@ Approuver Erreur d\'estimation des frais. Veuillez envoyer vos commentaires à l\'équide de support. Vous échangez + Vous envoyez Échanger ce montant de jetons sélectionnés aura un impact significatif sur les prix et réduira votre résultat. Impact élevé sur les prix Fonds insuffisants Donner l\'autorisation Échanger Échange... + Vous recevez à Vous recevez Choisir le jeton non disponible @@ -1638,7 +1641,7 @@ Confidentialité inégalée Obtenez votre carte Tangem Pay gratuite en quelques minutes Compte de paiement - Le compte de paiement n\'est pas synchronisé + Tangem Pay session expirée Code PIN invalide : évitez les séquences ou les répétitions Réémettre la carte Cette opération génère de nouvelles informations de carte. Vos anciennes informations cesseront de fonctionner. Vous ne pourrez pas annuler cette action. @@ -1655,8 +1658,11 @@ Les données ne peuvent pas être affichées pour le moment, mais les paiements par carte fonctionnent toujours. Définir le \ncode PIN Carte désactivée - Session expirée + Utilisez carte ou bague pour renouveler la session + Utilisez carte ou bague pour renouveler la session + Restaurer l\'accès Restaurer l\'accès + Tangem Pay session expirée Utilisez USDC pour les paiements quotidiens Tangem Pay est temporairement indisponible Tangem Pay @@ -2141,6 +2147,18 @@ Non, envoyer toute la somme Réduire de %s XTZ Pour ne pas payer un fraid de commissions élevé la prochaine fois que vous rechargez votre portefeuille, veuillez réduire le montant de %s XTZ + Explorez le Mode de Rendement + Bonus de première activation! + Offre spéciale pour le Mode de Rendement + 3x APY + Activez le Mode de Rendement pour la première fois et obtenez un rendement jusqu\'à 3 fois supérieur pour les 30 premiers jours + Bonus APR du premier mois + Vous percevez le rendement du marché + le bonus. Le bonus est versé en une fois en USDT ou USDC dans les 14 jours suivant la fin de la période de 30 jours. Disponible jusqu\'à épuisement du budget promotionnel. Conditions générales applicables. + Résumé + Gardez vos fonds en Mode de Rendement pendant 30 jours consécutifs. Le bonus est calculé sur le rendement réellement généré durant cette période + Comment en bénéficier + 3 × le rendement du marché pendant les 30 premiers jours\nÉligibilité minimale : 1$ de rendement du marché accumulé sur 30 jours\nBonus maximum : 50$ + Ce que vous gagnez Vos fonds sont actuellement fournis au protocole Aave, mais vous pouvez les gérer à tout moment. Vos %s sont fournis à Aave. Le transfert de %1$s %2$s vers Aave est en attente. diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index 45d98693f9..82a796f48e 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -196,7 +196,7 @@ Privacy senza rivali Ottieni la tua carta Tangem Pay gratuita in pochi minuti Conto di pagamento - Il conto di pagamento non è sincronizzato + Tangem Pay sessione scaduta PIN non valido: evitare sequenze o ripetizioni Riemettere la carta Questo genererà un nuovo set di dati della carta. I tuoi vecchi dati smetteranno di funzionare. Non puoi annullare questa operazione. @@ -212,7 +212,9 @@ Servizio temporaneamente non disponibile Al momento non è possibile visualizzare i dati, ma i pagamenti con carta continuano a funzionare. Carta disattivata - Sessione scaduta + Usa la carta o l\'anello per rinnovare la sessione + Usa la carta o l\'anello per rinnovare la sessione + Tangem Pay sessione scaduta Usa USDC per i pagamenti quotidiani Tangem Pay è temporaneamente non disponibile Tangem Pay diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 62c071a8af..f7f5c9d0cb 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -359,6 +359,7 @@ わかりました ブラウザで開く + 設定を開く または プライマリーカード プライマリーリング @@ -1053,7 +1054,7 @@ その他のオプション 秘密鍵はチップ内で安全に生成されます。シードフレーズは存在しないので、誰もエクスポートしたり盗んだりすることはできません。 秘密鍵を非公開で生成する - 続行すると、以下に同意したものとみなされます。 + 続行すると、以下に同意したものとみなされます。\n%s カードは有効化され、使用可能になりました 成功! ウォレットの設定が完了し、使用できるようになりました。 @@ -1141,12 +1142,17 @@ サービスは外部プロバイダーによって提供されます。 \nTangemは責任を負いません。 買付金額は%s以下にしてください 買付金額は少なくとも%sである必要があります + 累計取引額が%1sを超えると、%2sでの本人確認が必要になる場合があります。 + 累計取引額が%1s相当額を超えると、%2sでの本人確認が必要になる場合があります。 + 「支払う」をタップすると、%1sの%2sおよび%3sに同意したものとみなされます。 この通貨で利用可能なプロバイダーはありません 最短で処理 支払う 支払方法 最大 %s まで使用可能 %s 以上で利用可能 + 米国および英国発行のカードは、この方法では処理できません。プロバイダーにより、追加の本人確認が求められる場合があります。 + プロバイダー要件 %dプロバイダー @@ -1175,10 +1181,21 @@ %s 経由 支払い グループ + ネットワーク別に表示 + 残高順に並べ替え 残高順 トークンを整理する グループ解除 %sサポート + プッシュ通知は有効になっていますが、端末の設定で通知を許可するまで動作しません。 + 通知を許可する + 製品ニュース、限定オファー、アクティビティのリマインダー。 + オファー・最新情報 + 主要銘柄の価格変動を通知で受け取れます。 + 価格アラート + 通知設定 + 取引・スワップ・重要な更新に関するリアルタイム通知。 + 取引アラート 詳細はこちら Tangemの通知は設定で有効にできます。 後で有効にする @@ -1606,6 +1623,10 @@ 残高不足 この取引を完了するには残高が不足しています。受け取り額を減らすか、資金を追加してください。 許可を与える + プロバイダーの利用体験を評価してください + フィードバックを入力してください + フィードバックを送信 + ご利用体験に影響した点は\n何ですか? スワップ スワップ中… 受け取り先 @@ -1621,6 +1642,10 @@ カード名を変更できません カードが凍結されています カード決済 + 支払いアカウントから削除されます。 + カードを解約する + 戻る + カードを解約しますか? 入金 異議申し立て 取引を表示 @@ -1667,7 +1692,7 @@ CVC データの読み込みに失敗しました。しばらくしてからもう一度お試しください。 有効期限 - カードの一時停止 + カードを凍結する 詳細を隠す 非表示 Googleウォレットを開く @@ -1708,6 +1733,8 @@ カード名 %s以上の金額を設定してください 限度額を設定できませんでした。もう一度お試しください + 通常、最大5分ほどかかります。 + カードを解約しています 変更 現在の利用限度額 1日の利用限度額を読み込めませんでした。もう一度お試しください。 @@ -1728,7 +1755,7 @@ カードの発行に失敗しました 技術的なエラーが発生しました。下のボタンをクリックして、もう一度お試しください。 技術的なエラーが発生しました。サポートへお問い合わせください。 - まもなくご利用いただけるようになります + 近日中に利用可能になります 支払いアカウントで追加カードを発行できるようになります。 無料のTangem Visaバーチャルカードを入手 Tangem Payを入手 @@ -1774,7 +1801,7 @@ 無料のTangem Payカードを数分でゲットしましょう Payサポート 支払いアカウント - 支払アカウントが同期されていません + Tangem Pay セッションの有効期限が切れました 無効な暗証番号:連続や繰り返しを避けてください カードを交換 これにより、新しいカード情報が発行されます。現在のカード情報は使えなくなります。この操作は元に戻せません。 @@ -1792,8 +1819,11 @@ \nPINコードの設定 カード無効化済み カードを交換中 - セッションの有効期限が切れました + カードまたはリングでセッションを更新してください + カードまたはリングでセッションを更新してください + セッションを更新 セッションを更新 + Tangem Pay セッションの有効期限が切れました 日常の支払いにUSDCを利用 Tangem Payは現在一時的に利用できません。 Tangem Pay @@ -1822,6 +1852,7 @@ 利用可能残高 合計残高 年利最大%s + 最大%sAPY XPUBを生成する 非表示 このトークンをメイン画面から非表示にします。トークンの管理ページからいつでも再度追加できます。 @@ -2153,6 +2184,8 @@ バックアップがありません このカードは以前取引に使用されたことがあります。信頼できない出所から受け取った場合は、全資金を引き出すことを検討してください。あなたのカードであれば、何もする必要はありません。 カードはすでに取引に署名済みです + 順次更新されます。 + 一部トークンの残高が表示されていません。 あなたのレビューは、Tangemウォレットをさらに良くするためのモチベーションになります Tangemを楽しんでいますか? トークンを受け取る前に、トークンを関連付ける必要があります。 @@ -2302,6 +2335,20 @@ いいえ、すべて送信します %s XTZを減らす 次回ウォレットにチャージするときに手数料の増加を避けるには、金額を%s XTZ減らしてください。 + 利息モードを見る + 初回限定ボーナス! + 利息モード限定オファー + APY 3倍 + APYブーストを有効にする + 30日間APYブーストの対象です。利用規約が適用されます。詳細はこちら。 + 初めて利息モードを有効にすると、最初の30日間は最大3倍の利回りを獲得できます。 + 初月APRボーナス + 市場利回りに加えてボーナスを獲得できます。ボーナスは30日間の期間終了後、14日以内にUSDTまたはUSDCで一度だけ支払われます。プロモーション予算がなくなり次第終了します。利用規約が適用されます + 概要 + 30日間連続で利息モードに資金を預けてください。ボーナスは、その期間中に実際に獲得した利回りを基準に計算されます。 + 対象条件 + 最初の30日間は市場利回りの3倍\n対象条件:30日間で市場利回りを$1以上獲得\n最大ボーナス:$50 + 受取額 利息モードが有効な場合、このアドレスへの今後の入金はすべてAaveに提供されます。資金は引き続き自由に管理できます。 %sはAaveに供給されています %1$s %2$sをAaveへ供給中 @@ -2402,5 +2449,7 @@ %s手数料を支払えません 現在、利息モードはご利用いただけません。しばらくしてからもう一度お試しください。 利息モードは利用できません + ボーナス支払いの対象条件を確認しています + ボーナス獲得まで チャートを読み込めません・・ diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 64d51c812e..bceb22fb6c 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -369,6 +369,7 @@ Agora OK Abrir no navegador + Abra as configurações ou Cartão principal Anel primário @@ -1071,7 +1072,7 @@ Outras opções Suas chaves serão geradas com segurança dentro do chip. Não há frase mnemônica, o que significa que ninguém pode exportá-las ou roubá-las. Gere chaves de forma privada - Ao continuar, você concorda com os termos. %1$s + Ao continuar, você concorda com os termos. %s Seu cartão está ativado e pronto para uso. Sucesso! Sua carteira está configurada e pronta para uso! @@ -1161,12 +1162,17 @@ O serviço é fornecido por um provedor externo. A Tangem não se responsabiliza por ele. O valor da compra não deve ser superior a %s O valor da compra deve ser de pelo menos %s + Valor total acumulado das transações acima de %1s pode exigir verificação de identidade com %2s + Valor total acumulado das transações acima do equivalente a %1s pode exigir verificação de identidade com %2s + Ao clicar em Pagar, você concorda com %1s\'s %2s e %3s. Não há fornecedores disponíveis para esta moeda. Processamento mais rápido Pagar com Método de pagamento Disponível até %s Disponível em %s + Cartões emitidos nos EUA e no Reino Unido não podem ser processados ​​por este método. O provedor pode exigir verificação de identidade adicional. + Requisitos do fornecedor UM OUTRO @@ -1203,6 +1209,15 @@ Organizar tokens Desagrupar %s suporte + As notificações push estão ativadas, mas só funcionarão depois que você as permitir nas configurações do seu dispositivo. + Permitir notificações + Novidades sobre produtos, ofertas exclusivas e lembretes de atividades. + Ofertas e atualizações + Receba notificações sobre mudanças de preço das principais criptomoedas do mercado. + Alertas de preço + Configurações de notificação + Alertas em tempo real para transações, câmbio e atualizações críticas. + Alertas de transação Mais informações Você pode ativar as notificações do Tangem nas Configurações. Ativar mais tarde @@ -1633,6 +1648,10 @@ Fundos insuficientes Não há fundos suficientes para concluir esta transação. Reduza o valor a receber ou adicione mais fundos. Conceder permissão + Avalie sua experiência com o fornecedor. + Digite seu feedback + Enviar feedback + O que afetou sua experiência? Trocar Trocar... Você recebe para @@ -1648,6 +1667,10 @@ Não foi possível renomear o cartão. Cartão bloqueado Pagamento com cartão + O valor desaparecerá da conta de pagamento. + Fechar cartão + Voltar + Fechar o cartão? Depósito Disputa Explorar transação @@ -1735,6 +1758,8 @@ Nome do cartão Definir um limite a partir de %s Não foi possível definir o limite. Tente novamente. + Geralmente leva até 5 minutos + Fechando seu cartão Mudar Limite atual Não foi possível carregar seu limite diário. Tente novamente. @@ -1802,7 +1827,7 @@ Obtenha seu cartão Tangem Pay gratuito em minutos. Suporte de Pay Conta de pagamento - A conta de pagamento não está sincronizada. + Tangem Pay sessão expirada PIN inválido: evite sequências ou repetições. Substituir cartão Isso gera um novo conjunto de dados do cartão. Seus dados antigos deixarão de funcionar. Você não pode desfazer essa ação. @@ -1820,8 +1845,11 @@ Defina o código PIN. Cartão desativado Substituindo seu cartão - Sessão expirada + Use o cartão ou anel para renovar a sessão + Use o cartão ou anel para renovar a sessão + Restaurar acesso Restaurar acesso + Tangem Pay sessão expirada Use USDC para pagamentos do dia a dia. O serviço Tangem Pay está temporariamente inacessível. Tangem Pay @@ -1850,6 +1878,7 @@ Saldo disponível Saldo total Ganhe até %s um ano + Até %s APY Gerar XPUB Ocultar Você está prestes a ocultar este token da tela principal. Você pode adicioná-lo novamente a qualquer momento através da página de gerenciamento de tokens. @@ -2184,6 +2213,8 @@ Backup ausente Este cartão já foi usado anteriormente para transações. Se o recebeu de uma fonte não confiável, considere retirar todos os fundos. Se o cartão for seu, nenhuma ação é necessária. O cartão já registrou transações. + Será atualizado assim que possível. + Faltam alguns saldos de tokens Sua avaliação nos motiva a aprimorar ainda mais a Tangem Wallet. Gostando de Tangem? Você precisa associar seu token antes de receber tokens. @@ -2333,6 +2364,20 @@ Não, envie tudo Reduzir por %s XTZ Para evitar pagar uma comissão maior na próxima vez que recarregar sua carteira, reduza o valor em %s XTZ + Explore o modo Yield + Bônus de ativação pela primeira vez! + Oferta especial para o modo Yield + APY x3 + yield_apy_boost_block_activate + Você tem direito a um aumento de APY por 30 dias. Aplicam-se os termos e condições. Saiba mais. + Ative o Modo de Rendimento pela primeira vez e obtenha até 3 vezes mais rendimento nos seus primeiros 30 dias. + Bônus de APR no primeiro mês + Você recebe rendimento de mercado + bônus. O bônus é pago uma única vez em USDT ou USDC dentro de 14 dias após o término do período de 30 dias. Disponível enquanto durar o orçamento promocional. Aplicam-se os termos e condições. + Resumo + Mantenha os fundos no Modo de Rendimento por 30 dias consecutivos. O bônus é baseado no rendimento que você realmente obtiver durante esse período. + Como se qualificar + 3 vezes o rendimento de mercado nos primeiros 30 dias\nElegibilidade mínima: US$ 1 de rendimento de mercado acumulado por 30 dias\nBônus máximo: US$ 50 + Quanto você recebe Quando o Modo de Rendimento estiver ativo, todas as recargas futuras para este endereço serão fornecidas à Aave. Você ainda poderá gerenciar seus fundos livremente. Seu %s é fornecido à Aave Fornecimento %1$s %2$s para Aave @@ -2433,5 +2478,7 @@ Não foi possível cobrir %s taxa O Modo Rendimento não está disponível no momento. Tente novamente mais tarde. Modo de rendimento indisponível + A elegibilidade para o pagamento do bônus é avaliada. + falta desbloquear seu bônus Não foi possível carregar o gráfico... diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index bfd2cb942b..10e73a5a0a 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -1590,6 +1590,7 @@ Подтверждая, вы разрешаете смарт-контракту использовать ваши токены в будущих транзакциях. Фиксированный курс Комиссия сети за одобрение токена будет взиматься за подтверждение того, что именно вы разрешаете использовать ваш токен для обмена. + Обмен в процессе Обменивайте больше токенов по лучшим курсам прямо в вашем кошельке. Новый провайдер обмена! Найдите любой токен, даже если его ещё нет в вашем списке @@ -1671,7 +1672,7 @@ Разморозить карту? Не удалось разморозить карту, попробуйте еще раз Карта разморожена - Вывести + Вывод средств Это произошло из-за регуляторных требований. Вывод средств по-прежнему доступен. Карта была деактивирована Запрещено использовать на root-устройствах @@ -1778,7 +1779,7 @@ Откройте виртуальную \nTangem Pay Card Поддержка Pay Платежный аккаунт - Платежный аккаунт не синхронизирован + Tangem Pay · Cессия истекла Слабый ПИН: не используйте повторы или последовательности. Перевыпустить Будет создана новая карта, старая перестанет работать. Отменить это действие нельзя. @@ -1794,8 +1795,11 @@ Сервис временно недоступен Не можем показать данные карты, но оплаты продолжают работать. Карта отключена - Сессия истекла + Используйте карту или кольцо для обновления сессии + Используйте карту или кольцо для обновления сессии + Обновить сессию Обновить сессию + Tangem Pay · Cессия истекла Оплачивайте ежедневные покупки в USDC Tangem Pay временно недоступен Tangem Pay @@ -2237,6 +2241,18 @@ Нет, отправить все Уменьшить на %s XTZ Чтобы не платить повышенную комиссию при следующем пополнении кошелька, уменьшите сумму на %s XTZ + Посмотреть режим доходности + Бонус за первую активацию! + Спецпредложение для режима доходности + APY x3 + Включите режим доходности впервые и получите до 3x дохода за первые 30 дней + Бонус APR за первый месяц + Вы получаете рыночный доход + бонус. Бонус выплачивается единоразово в USDT или USDC в течение 14 дней после окончания 30-дневного периода. Акция действует, пока есть промо-бюджет. Действуют правила и условия + Итоги + Храните средства в режиме доходности 30 дней подряд. Бонус рассчитывается от вашего реального дохода за этот период + Как получить бонус + 3x к рыночному доходу за первые 30 дней\nМин. порог: $1 накопленного рыночного дохода за 30 дней\nМакс. бонус: $50 + Сколько вы получите При активном режиме доходности все будущие депозиты на этот адрес будут направляться в Aave. Вы по-прежнему можете свободно управлять своими средствами. Ваш %s внесён в Aave Отправка %1$s %2$s в Aave diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index e246aefdb0..fc2bbcf1f6 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1511,6 +1511,7 @@ Підтверджуючи, ви дозволяєте смартконтракту використовувати ваші токени в майбутніх транзакціях. Фіксований курс Мережа стягує комісію за схвалення токену за підтвердження, що саме ви дозволяєте використовувати ваш токен для обміну. + Обмін у процесі Обмінюйте більше токенів за вигіднішим курсом прямо у своєму гаманці. З\'явився новий провайдер обмінів! Шукаєте щось інше?\nСпробуйте пошукати або перегляньте інші криптовалюти! @@ -1545,12 +1546,14 @@ Підтвердити Помилка при розрахунку комісії. Будь ласка, надішліть відгук до служби підтримки. Ви обмінюєте + Ви надсилаєте Обмін цієї кількості обраних токенів призведе до значного впливу на ціну і зменшить вашу кінцеву суму. Високий вплив на ціну Недостатньо коштів Надати дозвіл Обміняти Обмін... + Ви отримаєте на Ви отримаєте Оберіть токен недоступно @@ -1692,7 +1695,7 @@ Неперевершена конфіденційність Отримайте безкоштовну картку Tangem Pay за лічені хвилини Платіжний акаунт - Платіжний рахунок не синхронізовано + Tangem Pay · Сесія закінчилася Слабкий ПІН: не використовуйте повторів або послідовностей. Перевипустити Це створить новий набір реквізитів картки. Ваші старі реквізити перестануть працювати. Ви не зможете скасувати цю дію. @@ -1709,8 +1712,11 @@ Не можемо показати дані картки, але оплати продовжують працювати. Встановіть \nPIN-код Картку деактивовано - Сесія закінчилася + Використайте картку або кільце для поновлення сесії + Використайте картку або кільце для поновлення сесії + Відновити доступ Відновити доступ + Tangem Pay · Сесія закінчилася Використовуйте USDC для щоденних платежів Tangem Pay тимчасово недоступний Tangem Pay @@ -2161,6 +2167,18 @@ Ні, відправити все Зменшити на %s XTZ Щоб не платити підвищену комісію при наступному поповненні гаманця, зменште суму на %s XTZ + Переглянути режим дохідності + Бонус за першу активацію! + Спецпропозиція для режиму дохідності + APY x3 + Увімкніть режим дохідності вперше та отримайте до 3x доходу за перші 30 днів + Бонус APR за перший місяць + Ви отримуєте ринковий дохід + Бонус. Бонус виплачується одноразово в USDT або USDC протягом 14 днів після закінчення 30-денного періоду. Акція діє, доки є промо-бюджет. Діють правила та умови + Підсумки + Зберігайте кошти у режимі дохідності 30 днів поспіль. Бонус розраховується від вашого реального доходу за цей період + Як отримати бонус + 3x до ринкового доходу за перші 30 днів\nМін. поріг: $1 накопиченого ринкового доходу за 30 днів\nМакс. бонус: $50 + Скільки ви отримаєте З активним режимом дохідності всі майбутні депозити на цю адресу будуть надходити до Aave. Ви все ще можете вільно розпоряджатися своїми коштами. Ваш %s внесений до Aave Передача %1$s %2$s до Aave очікується diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index 1369fb0631..035a3d3260 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -359,6 +359,7 @@ 现在 好的 在浏览器中打开 + 打开设置 或者 主卡 主指环 @@ -1053,7 +1054,7 @@ 其他选项 您的密钥将在芯片内部安全生成,没有助记词,这意味着任何人都无法导出或窃取它。 私下生成密钥 - 如继续,即表示您同意 + 如继续,即表示您同意以下条款:\n%s 您的卡已激活,可以使用了。 成功! 您的钱包已设置完毕,可以使用了! @@ -1141,12 +1142,17 @@ 服务由外部供应商提供。\nTangem对此不承担任何责任。 购买金额不应超过 %s 购买金额必须至少 %s + 累计交易金额超过 %1s 时,可能需要通过 %2s进行身份验证 + 累计交易金额超过等值金额 %1s 可能需要通过 %2s进行身份验证 + 点击“支付”即表示您同意 %1s的 %2s 和 %3s。 目前没有提供此货币的供应商 最快处理 支付方式 付款方式 最多可 %s 可从 %s + 美国和英国发行的银行卡无法通过此方式处理。服务提供商可能需要额外的身份验证。 + 服务提供商要求 提供者 @@ -1181,6 +1187,15 @@ 整理代币 取消分组 %s 支持 + 推送通知已启用,但需要您在设备设置中允许通知才能正常工作。 + 允许通知 + 产品资讯、独家优惠和活动提醒。 + 优惠与更新 + 获取热门市场加密货币价格变动的通知。 + 价格提醒 + 通知设置 + 实时提醒交易、兑换和重要更新。 + 交易提醒 更多信息 您可以在设置中启用 Tangem 的通知。 稍后启用 @@ -1608,6 +1623,10 @@ 资金不足 账户余额不足,无法完成此交易。请减少收款金额或增加余额。 给予许可 + 请评价您与服务提供商的互动体验 + 请输入您的反馈 + 发送反馈 + 是什么影响了您的\n体验? 兑换 互换... 您收到 @@ -1623,6 +1642,10 @@ 无法重命名卡片 卡片已冻结 卡片支付 + 它将从付款账户中消失 + 关闭卡片 + 返回 + 关闭您的卡片? 存款 争议 探索交易 @@ -1710,6 +1733,8 @@ 卡片名称 从 %s设定一个限额 我们无法设置限额,请稍后再试。 + 通常需要最多 5 分钟 + 关闭您的卡片 改变 当前限额 我们无法加载您的每日限额。请稍后再试。 @@ -1776,7 +1801,7 @@ 几分钟内即可获得免费的 Tangem Pay 卡 支付支持 支付账户 - 支付账户未同步 + Tangem Pay 会话已过期 无效PIN码:请避免使用连续或重复的密码。 更换卡片 这将生成一组新的卡片信息。您原有的信息将失效。此操作无法撤销。 @@ -1794,8 +1819,11 @@ 设置 PIN 码 卡片已停用 更换您的卡片 - 会话已过期 + 用卡或戒指续期会话 + 用卡或戒指续期会话 + 恢复访问权限 恢复访问权限 + Tangem Pay 会话已过期 使用 USDC 进行日常支付 Tangem Pay暂时无法使用。 Tangem Pay @@ -1824,6 +1852,7 @@ 可用余额 总余额 年收入高达 %s + 高达 %s APY 生成 XPUB 隐藏 您即将从主屏幕隐藏此代币。您可以随时通过“管理代币”页面将其重新添加。 @@ -2155,6 +2184,8 @@ 缺少备份 此卡曾用于交易。如果是从不可信来源收到的,请考虑提取所有资金。如果是您的卡,则无需采取任何措施。 卡片已签署交易 + 将尽快更新 + 缺少部分代币余额 您的评价激励我们不断改进 Tangem Wallet。 喜欢 Tangem 吗? 您必须先关联您的代币才能接收代币。 @@ -2304,6 +2335,20 @@ 不,全部发送 减少 %s XTZ 为避免下次充值时支付更高的手续费,请按 %s XTZ减少充值金额。 + 探索收益模式 + 首次激活奖励! + 收益模式特惠 + APY x3 + yield_apy_boost_block_activate + 您有资格获得 30 天的年利率提升,适用条款和条件,了解更多信息 + 首次激活收益模式,即可在前 30 天内获得高达 3 倍的收益。 + 首月年利率奖励 + 您将获得市场收益 + 奖励。奖励将在 30 天期限结束后 14 天内以 USDT 或 USDC 形式一次性发放。活动额度有限,售完即止。须遵守相关条款和条件。 + 摘要 + 连续 30 天保持资金在收益模式下。奖励根据您在此期间实际赚取的收益率计算 + 如何获得资格 + 前30天可获得3倍市场收益率\n最低资格:累计30天市场收益率达1美元\n最高奖励:50美元 + 您能得到多少 启用收益模式后,所有未来充值到此地址的资金都将转入 Aave。您仍然可以自由管理您的资金。 你的 %s 提供给 Aave 供应 %1$s %2$s 到 Aave @@ -2404,5 +2449,7 @@ 无法覆盖 %s 费用 收益模式暂时不可用。请稍后再试。 收益模式不可用 + 奖金发放资格已评估 + 离开即可解锁您的奖励 无法加载图表... diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index bd779ef436..cd7b619b41 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -422,14 +422,16 @@ 無與倫比的隱私 在幾分鐘內獲得免費的 Tangem Pay 卡 付款帳戶 - 付款帳戶未同步 + Tangem Pay 工作階段已過期 這將產生一組新的卡片資料。您的舊資料將停止使用。此操作無法復原。 要重新發行您的卡片嗎? 我们正在修复技术问题。请稍后再试。 服務暫時無法使用 目前無法顯示資料,但卡片支付仍可正常使用。 卡片已停用 - 工作階段已過期 + 用卡或戒指續期會話 + 用卡或戒指續期會話 + Tangem Pay 工作階段已過期 使用 USDC 進行日常支付 Tangem Pay暂时不可用 Tangem Pay diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 26d5547fcb..a6a6b012a1 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -369,6 +369,7 @@ Now OK Open in Browser + Open Settings or Primary card Primary ring @@ -1163,6 +1164,7 @@ The purchase amount should be no more than %s The amount to buy must be at least %s Cumulative transaction amount over %1s may require identity verification with %2s + Cumulative transaction amount over equivalent of %1s may require identity verification with %2s By clicking Pay, you agree to %1s\'s %2s and %3s. No available providers for this currency Quickest processing @@ -1208,6 +1210,8 @@ Organize tokens Ungroup %s support + Push Notifications are enabled but won\'t work until you allow notifications in your device settings + Allow notifications Product news, exclusive offers, and activity reminders. Offers & Updates Get notified about price changes for top market coins. @@ -1645,6 +1649,10 @@ Insufficient funds Not enough funds to complete this transaction. Reduce the amount to receive or add more funds. Give Permission + Rate your experience with provider + Type your feedback + Send feedback + What affected your \nexperience? Swap Swapping... You receive to @@ -1660,6 +1668,10 @@ Unable to rename card Card frozen Card payment + It will disappear from payment account + Close card + Go back + Close your card? Deposit Dispute Explore transaction @@ -1747,6 +1759,8 @@ Card name Set a limit from %s We couldn’t set the limit. Please try again + Usually takes up to 5 minutes + Closing your card Change Current limit We couldn\'t load your daily limit. Please try again. @@ -1814,7 +1828,7 @@ Get your free Tangem Pay Card in minutes Pay Support Payment account - Payment account is not synced + Payment account session expired Invalid PIN: avoid sequences or repeats Replace card This generates a new set of card details. Your old details will stop working. You can\'t undo this. @@ -1832,8 +1846,11 @@ Set \nPIN code Card deactivated Replacing your card - Session expired + Use your card or ring to renew session + Use your card or ring to renew session + Renew session Renew session + Payment account session expired Use USDC for everyday payments Tangem Pay is temporarily unreachable Tangem Pay @@ -2349,6 +2366,12 @@ No, send all Reduce by %s XTZ To avoid paying an increased commission the next time you top up your wallet, reduce the amount by %s XTZ + Explore Yield mode + First time activation bonus! + Special offer for Yield mode + APY x3 + yield_apy_boost_block_activate + You are eligible for 30 days APY boost, T&C apply, learn more Activate Yield Mode for the first time and get up to 3x yield for your first 30 days First month APR bonus You get market yield + Bonus. Bonus is paid once in USDT or USDC within 14 days after the 30-day period ends. Available while promo budget lasts. Terms and conditions apply @@ -2457,5 +2480,7 @@ Unable to cover %s fee Yield Mode isn\'t available at the moment. Please try again later. Yield Mode unavailable + Bonus payout eligibility is assessed + left to unlock your bonus Unable to load chart... diff --git a/core/ui/src/main/res/drawable/ic_rating_star_24.xml b/core/ui/src/main/res/drawable/ic_rating_star_24.xml new file mode 100644 index 0000000000..5712107c89 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_rating_star_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/features/rating/api/build.gradle.kts b/features/rating/api/build.gradle.kts new file mode 100644 index 0000000000..f11af4f840 --- /dev/null +++ b/features/rating/api/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/features/rating/api/src/main/java/com/tangem/features/rating/RatingComponent.kt b/features/rating/api/src/main/java/com/tangem/features/rating/RatingComponent.kt new file mode 100644 index 0000000000..b5ed0e3fde --- /dev/null +++ b/features/rating/api/src/main/java/com/tangem/features/rating/RatingComponent.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/rating/impl/build.gradle.kts b/features/rating/impl/build.gradle.kts new file mode 100644 index 0000000000..ccc7ed6d9f --- /dev/null +++ b/features/rating/impl/build.gradle.kts @@ -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().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) +} \ No newline at end of file diff --git a/features/rating/impl/src/main/java/com/tangem/feature/rating/DefaultRatingComponent.kt b/features/rating/impl/src/main/java/com/tangem/feature/rating/DefaultRatingComponent.kt new file mode 100644 index 0000000000..703d60f32e --- /dev/null +++ b/features/rating/impl/src/main/java/com/tangem/feature/rating/DefaultRatingComponent.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/rating/impl/src/main/java/com/tangem/feature/rating/di/RatingModule.kt b/features/rating/impl/src/main/java/com/tangem/feature/rating/di/RatingModule.kt new file mode 100644 index 0000000000..1f5a5e23b6 --- /dev/null +++ b/features/rating/impl/src/main/java/com/tangem/feature/rating/di/RatingModule.kt @@ -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 +} \ No newline at end of file diff --git a/features/rating/impl/src/main/java/com/tangem/feature/rating/model/RatingModel.kt b/features/rating/impl/src/main/java/com/tangem/feature/rating/model/RatingModel.kt new file mode 100644 index 0000000000..35ca1d3bb5 --- /dev/null +++ b/features/rating/impl/src/main/java/com/tangem/feature/rating/model/RatingModel.kt @@ -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 + 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) + }, + ) + } + } +} \ No newline at end of file diff --git a/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingBlock.kt b/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingBlock.kt new file mode 100644 index 0000000000..bb8b986ee3 --- /dev/null +++ b/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingBlock.kt @@ -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), + ) + } + } + } +} \ No newline at end of file diff --git a/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingFeedbackBS.kt b/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingFeedbackBS.kt new file mode 100644 index 0000000000..235f17db56 --- /dev/null +++ b/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingFeedbackBS.kt @@ -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 \ No newline at end of file diff --git a/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingFeedbackBottomSheet.kt b/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingFeedbackBottomSheet.kt new file mode 100644 index 0000000000..99b5f40fd0 --- /dev/null +++ b/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingFeedbackBottomSheet.kt @@ -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( + 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, + ) + }, + ) + }, + ) +} \ No newline at end of file diff --git a/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingUM.kt b/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingUM.kt new file mode 100644 index 0000000000..6db93f77eb --- /dev/null +++ b/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingUM.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/rating/impl/src/test/java/com/tangem/feature/rating/model/RatingModelTest.kt b/features/rating/impl/src/test/java/com/tangem/feature/rating/model/RatingModelTest.kt new file mode 100644 index 0000000000..ccfcf0b31f --- /dev/null +++ b/features/rating/impl/src/test/java/com/tangem/feature/rating/model/RatingModelTest.kt @@ -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() + 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()) } + } + + @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()) } + } +} \ No newline at end of file diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt index 834da0be35..5487caa5d2 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt @@ -5,4 +5,5 @@ interface SwapFeatureToggles { val isSwapIntegratedApproveEnabled: Boolean val isSwapAbEnabled: Boolean val isSwapProviderFilterEnabled: Boolean + val isSwapRateExperienceEnabled: Boolean } \ No newline at end of file diff --git a/features/swap/data/build.gradle.kts b/features/swap/data/build.gradle.kts index dd4eda6edb..efe6438a1b 100644 --- a/features/swap/data/build.gradle.kts +++ b/features/swap/data/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { /** Network */ implementation(deps.retrofit) + implementation(deps.retrofit.moshi) implementation(deps.moshi) implementation(deps.moshi.kotlin) implementation(deps.arrow.core) diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapFeedbackRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapFeedbackRepository.kt new file mode 100644 index 0000000000..51bba4a201 --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapFeedbackRepository.kt @@ -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 { + 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 { + 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, + ), + ), + ) + } + } +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/NoOpSwapFeedbackRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/NoOpSwapFeedbackRepository.kt new file mode 100644 index 0000000000..f35063dd18 --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/NoOpSwapFeedbackRepository.kt @@ -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 = null.right() + + override suspend fun submitFeedback(params: SwapFeedbackParams): Either = Unit.right() +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt index 6057e6344e..c9cdcbbc86 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt @@ -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, + ) + } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapFeedbackUseCase.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapFeedbackUseCase.kt new file mode 100644 index 0000000000..ea11c04438 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapFeedbackUseCase.kt @@ -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 = + repository.getRating(txExternalId) + + suspend fun submit(params: SwapFeedbackParams): Either = repository.submitFeedback(params) +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapFeedbackRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapFeedbackRepository.kt new file mode 100644 index 0000000000..612952da16 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapFeedbackRepository.kt @@ -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 + suspend fun submitFeedback(params: SwapFeedbackParams): Either +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt index 36986b34c4..0555412b3e 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt @@ -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 diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExistingRating.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExistingRating.kt new file mode 100644 index 0000000000..818e03c7ac --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExistingRating.kt @@ -0,0 +1,3 @@ +package com.tangem.feature.swap.domain.models.domain + +data class ExistingRating(val rating: Int) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeedbackParams.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeedbackParams.kt new file mode 100644 index 0000000000..a2096cacd7 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeedbackParams.kt @@ -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, +) \ No newline at end of file diff --git a/features/swap/domain/src/test/java/com/tangem/feature/swap/domain/SwapFeedbackUseCaseTest.kt b/features/swap/domain/src/test/java/com/tangem/feature/swap/domain/SwapFeedbackUseCaseTest.kt new file mode 100644 index 0000000000..b8b515bfd1 --- /dev/null +++ b/features/swap/domain/src/test/java/com/tangem/feature/swap/domain/SwapFeedbackUseCaseTest.kt @@ -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) } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index a170fffc23..34a653d071 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -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, + ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 37747cd1d6..c76dac772f 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -168,7 +168,7 @@ internal fun TangemPayDetailsScreen( } } } - expressTransactionsBottomSheetState?.content() + expressTransactionsBottomSheetState?.content(null) } } diff --git a/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsComponent.kt b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsComponent.kt index 3918b1b043..b2e7362937 100644 --- a/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsComponent.kt +++ b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/ExpressTransactionsComponent.kt @@ -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 diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index ecdf0aab3c..bd81777717 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -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) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index f6ab5f2511..a193fd3f49 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -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" + } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt index a4ea05d613..d77b48b150 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt @@ -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() } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index ae0d0401c3..1ee997f1dc 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -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 = SlotNavigation() + val ratingSlotNavigation = SlotNavigation() 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( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt index c5ff9805b6..84897cd1c0 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExpressStatusFactory.kt @@ -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) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 08b7dced0e..1f5913e64b 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -126,7 +126,7 @@ internal fun TokenDetailsScreen( ) } - expressState.bottomSheetSlot?.content() + expressState.bottomSheetSlot?.content(null) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt index 4dc699ba5e..d77790fea8 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt @@ -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, ) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheet.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheet.kt index 360c82a53a..f334816546 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheet.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheet.kt @@ -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) } } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBottomSheetContent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBottomSheetContent.kt index 581f05d757..6ce01c5bd9 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBottomSheetContent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBottomSheetContent.kt @@ -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, diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt index bb9c96653e..8f922d63a6 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt @@ -24,6 +24,7 @@ internal fun BaseExtension.configureCompose(project: Project) { contains(":features:onboarding") || // TODO: divide on api/impl after migrating all onboarding to module contains(Regex(pattern = ":presentation\$")) || contains(Regex(pattern = ":app\$")) || // TODO: [REDACTED_JIRA] + contains(Regex(pattern = ":features:rating:api\$")) || // provides Composable function contains(Regex(pattern = ":features:markets:api\$")) || // provides Composable function contains(Regex(pattern = ":features:feed:api\$")) || // provides Composable function contains(Regex(pattern = ":features:manage-tokens:api\$")) || // provides Composable function diff --git a/settings.gradle.kts b/settings.gradle.kts index a23f949e32..4c1a0fb251 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -253,6 +253,9 @@ include(":features:markets:impl") include(":features:onramp:api") include(":features:onramp:impl") +include(":features:rating:api") +include(":features:rating:impl") + include(":features:stories:api") include(":features:stories:impl")