Updated on 2026-08-14
This commit is contained in:
parent
6031a97ffe
commit
8d0f11d10b
67 changed files with 1527 additions and 41 deletions
14
features/rating/api/build.gradle.kts
Normal file
14
features/rating/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.features.rating.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.decompose)
|
||||
implementation(projects.core.ui)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
37
features/rating/impl/build.gradle.kts
Normal file
37
features/rating/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.feature.rating.impl"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.features.rating.api)
|
||||
|
||||
implementation(projects.core.decompose)
|
||||
implementation(projects.core.res)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
implementation(deps.compose.foundation)
|
||||
implementation(deps.compose.material3)
|
||||
implementation(deps.compose.ui)
|
||||
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
testImplementation(deps.test.junit5)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.coroutine)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
package com.tangem.feature.rating.model
|
||||
|
||||
import com.tangem.core.decompose.di.GlobalUiMessageSender
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.message.SnackbarMessage
|
||||
import com.tangem.feature.rating.ui.RatingFeedbackBS
|
||||
import com.tangem.feature.rating.ui.RatingUM
|
||||
import com.tangem.features.rating.RatingComponent
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class RatingModel @Inject constructor(
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
paramsContainer: ParamsContainer,
|
||||
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
|
||||
) : Model() {
|
||||
|
||||
private val params: RatingComponent.Params = paramsContainer.require()
|
||||
|
||||
val state: StateFlow<RatingUM>
|
||||
field = MutableStateFlow(
|
||||
RatingUM(
|
||||
state = RatingUM.RatingState.Loading,
|
||||
feedbackBottomSheet = TangemBottomSheetConfig.Empty,
|
||||
onRatingSelected = ::onRatingSelected,
|
||||
),
|
||||
)
|
||||
|
||||
init {
|
||||
loadRating()
|
||||
}
|
||||
|
||||
fun onRatingSelected(rating: Int) {
|
||||
state.update { current ->
|
||||
val ratingState = current.state as? RatingUM.RatingState.Unrated ?: return@update current
|
||||
current.copy(
|
||||
state = ratingState.copy(selectedRating = rating),
|
||||
feedbackBottomSheet = buildFeedbackBottomSheet(feedbackText = "", isSubmitting = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onFeedbackChanged(text: String) {
|
||||
state.update { current ->
|
||||
val bs = current.feedbackBottomSheet
|
||||
val content = bs.content as? RatingFeedbackBS ?: return@update current
|
||||
current.copy(feedbackBottomSheet = bs.copy(content = content.copy(feedbackText = text)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDismissFeedbackBottomSheet() {
|
||||
state.update { current ->
|
||||
current.copy(feedbackBottomSheet = current.feedbackBottomSheet.copy(isShown = false))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSubmit() {
|
||||
val current = state.value
|
||||
val ratingState = current.state as? RatingUM.RatingState.Unrated ?: return
|
||||
val selectedRating = ratingState.selectedRating ?: return
|
||||
val content = current.feedbackBottomSheet.content as? RatingFeedbackBS ?: return
|
||||
|
||||
state.update {
|
||||
current.copy(
|
||||
feedbackBottomSheet = current.feedbackBottomSheet.copy(
|
||||
content = content.copy(isSubmitting = true),
|
||||
),
|
||||
)
|
||||
}
|
||||
modelScope.launch {
|
||||
try {
|
||||
params.onSubmitRating(selectedRating, content.feedbackText)
|
||||
state.update { um ->
|
||||
um.copy(
|
||||
state = RatingUM.RatingState.AlreadyRated(selectedRating),
|
||||
feedbackBottomSheet = um.feedbackBottomSheet.copy(isShown = false),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("RatingModel: onSubmitRating failed", e)
|
||||
uiMessageSender.send(SnackbarMessage(message = resourceReference(R.string.common_something_went_wrong)))
|
||||
state.update { um ->
|
||||
val bsContent = um.feedbackBottomSheet.content as? RatingFeedbackBS ?: return@update um
|
||||
um.copy(
|
||||
feedbackBottomSheet = um.feedbackBottomSheet.copy(
|
||||
content = bsContent.copy(isSubmitting = false),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildFeedbackBottomSheet(feedbackText: String, isSubmitting: Boolean): TangemBottomSheetConfig {
|
||||
return TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = ::onDismissFeedbackBottomSheet,
|
||||
content = RatingFeedbackBS(
|
||||
feedbackText = feedbackText,
|
||||
isSubmitting = isSubmitting,
|
||||
onFeedbackChanged = ::onFeedbackChanged,
|
||||
onDismiss = ::onDismissFeedbackBottomSheet,
|
||||
onSubmit = ::onSubmit,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadRating() = modelScope.launch {
|
||||
val existingRating = try {
|
||||
params.onLoadRating()
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("RatingModel: onLoadRating failed", e)
|
||||
null
|
||||
}
|
||||
state.update { current ->
|
||||
current.copy(
|
||||
state = if (existingRating != null) {
|
||||
RatingUM.RatingState.AlreadyRated(existingRating)
|
||||
} else {
|
||||
RatingUM.RatingState.Unrated(selectedRating = null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
package com.tangem.feature.rating.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.PrimaryButton
|
||||
import com.tangem.core.ui.components.SpacerH12
|
||||
import com.tangem.core.ui.components.SpacerH16
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.buttons.small.TangemIconButton
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
internal fun RatingFeedbackBottomSheet(config: TangemBottomSheetConfig) {
|
||||
TangemBottomSheet<RatingFeedbackBS>(
|
||||
config = config,
|
||||
containerColor = TangemTheme.colors.background.secondary,
|
||||
addBottomInsets = false,
|
||||
title = { content ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = TangemTheme.dimens.spacing16,
|
||||
vertical = TangemTheme.dimens.spacing8,
|
||||
),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TangemIconButton(
|
||||
iconRes = R.drawable.ic_close_24,
|
||||
onClick = content.onDismiss,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(TangemTheme.dimens.size56)
|
||||
.clip(CircleShape)
|
||||
.background(TangemTheme.colors.icon.attention.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_rating_star_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.attention,
|
||||
modifier = Modifier.size(TangemTheme.dimens.size32),
|
||||
)
|
||||
}
|
||||
SpacerH12()
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.swapping_rate_feedback_title),
|
||||
style = TangemTheme.typography.h2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
SpacerH16()
|
||||
}
|
||||
},
|
||||
content = { content ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = TangemTheme.dimens.spacing16)
|
||||
.padding(top = TangemTheme.dimens.spacing16)
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
FeedbackTextField(
|
||||
value = content.feedbackText,
|
||||
onValueChange = content.onFeedbackChanged,
|
||||
)
|
||||
SpacerH16()
|
||||
PrimaryButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResourceSafe(R.string.swapping_rate_feedback_submit),
|
||||
onClick = content.onSubmit,
|
||||
showProgress = content.isSubmitting,
|
||||
)
|
||||
SpacerH16()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun FeedbackTextField(value: String, onValueChange: (String) -> Unit) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val fieldShape = RoundedCornerShape(TangemTheme.dimens.radius14)
|
||||
val colors = TextFieldDefaults.colors().copy(
|
||||
focusedContainerColor = TangemTheme.colors.field.focused,
|
||||
unfocusedContainerColor = TangemTheme.colors.field.focused,
|
||||
focusedTextColor = TangemTheme.colors.text.primary1,
|
||||
unfocusedTextColor = TangemTheme.colors.text.primary1,
|
||||
cursorColor = TangemTheme.colors.icon.primary1,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
)
|
||||
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = TangemTheme.dimens.size48),
|
||||
textStyle = TangemTheme.typography.body1.copy(color = TangemTheme.colors.text.primary1),
|
||||
cursorBrush = SolidColor(TangemTheme.colors.icon.primary1),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
maxLines = 3,
|
||||
singleLine = false,
|
||||
minLines = 3,
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = { innerTextField ->
|
||||
TextFieldDefaults.DecorationBox(
|
||||
value = value,
|
||||
innerTextField = innerTextField,
|
||||
enabled = true,
|
||||
singleLine = false,
|
||||
visualTransformation = VisualTransformation.None,
|
||||
interactionSource = interactionSource,
|
||||
shape = fieldShape,
|
||||
colors = colors,
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = TangemTheme.dimens.spacing16,
|
||||
vertical = TangemTheme.dimens.spacing12,
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.swapping_rate_feedback_placeholder),
|
||||
style = TangemTheme.typography.body1,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
package com.tangem.feature.rating.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.ui.message.SnackbarMessage
|
||||
import com.tangem.feature.rating.ui.RatingFeedbackBS
|
||||
import com.tangem.feature.rating.ui.RatingUM
|
||||
import com.tangem.features.rating.RatingComponent
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class RatingModelTest {
|
||||
|
||||
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
|
||||
|
||||
private fun buildModel(
|
||||
onLoadRating: suspend () -> Int? = { null },
|
||||
onSubmitRating: suspend (Int, String) -> Unit = { _, _ -> },
|
||||
): RatingModel {
|
||||
val params = RatingComponent.Params(
|
||||
onLoadRating = onLoadRating,
|
||||
onSubmitRating = onSubmitRating,
|
||||
)
|
||||
return RatingModel(
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
paramsContainer = MutableParamsContainer(params),
|
||||
uiMessageSender = uiMessageSender,
|
||||
)
|
||||
}
|
||||
|
||||
private val RatingModel.ratingState get() = state.value.state
|
||||
private val RatingModel.feedbackContent get() = state.value.feedbackBottomSheet.content as? RatingFeedbackBS
|
||||
|
||||
@Test
|
||||
fun `initial state is Loading before onLoadRating completes`() = runTest {
|
||||
val deferred = CompletableDeferred<Int?>()
|
||||
val model = buildModel(onLoadRating = { deferred.await() })
|
||||
assertThat(model.ratingState).isInstanceOf(RatingUM.RatingState.Loading::class.java)
|
||||
deferred.complete(null)
|
||||
assertThat(model.ratingState).isInstanceOf(RatingUM.RatingState.Unrated::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state is Unrated with no selection when onLoadRating returns null`() = runTest {
|
||||
val model = buildModel(onLoadRating = { null })
|
||||
val unrated = model.ratingState as RatingUM.RatingState.Unrated
|
||||
assertThat(unrated.selectedRating).isNull()
|
||||
assertThat(model.state.value.feedbackBottomSheet.isShown).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state is AlreadyRated when onLoadRating returns a rating`() = runTest {
|
||||
val model = buildModel(onLoadRating = { 4 })
|
||||
assertThat(model.ratingState).isEqualTo(RatingUM.RatingState.AlreadyRated(rating = 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onRatingSelected updates selectedRating and shows feedback bottom sheet`() = runTest {
|
||||
val model = buildModel(onLoadRating = { null })
|
||||
model.onRatingSelected(3)
|
||||
val unrated = model.ratingState as RatingUM.RatingState.Unrated
|
||||
assertThat(unrated.selectedRating).isEqualTo(3)
|
||||
assertThat(model.state.value.feedbackBottomSheet.isShown).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onRatingSelected is no-op when state is not Unrated`() = runTest {
|
||||
val model = buildModel(onLoadRating = { 4 })
|
||||
model.onRatingSelected(3)
|
||||
assertThat(model.ratingState).isEqualTo(RatingUM.RatingState.AlreadyRated(rating = 4))
|
||||
assertThat(model.state.value.feedbackBottomSheet.isShown).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onFeedbackChanged updates feedbackText in bottom sheet content`() = runTest {
|
||||
val model = buildModel(onLoadRating = { null })
|
||||
model.onRatingSelected(4)
|
||||
model.feedbackContent!!.onFeedbackChanged("Great service!")
|
||||
assertThat(model.feedbackContent!!.feedbackText).isEqualTo("Great service!")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onSubmit calls onSubmitRating with correct args`() = runTest {
|
||||
val submitMock: suspend (Int, String) -> Unit = mockk(relaxed = true)
|
||||
val model = buildModel(onLoadRating = { null }, onSubmitRating = submitMock)
|
||||
model.onRatingSelected(5)
|
||||
model.feedbackContent!!.onFeedbackChanged("Excellent!")
|
||||
model.feedbackContent!!.onSubmit()
|
||||
coVerify(exactly = 1) { submitMock(5, "Excellent!") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onSubmit transitions to AlreadyRated and hides bottom sheet on success`() = runTest {
|
||||
val model = buildModel(onLoadRating = { null }, onSubmitRating = { _, _ -> })
|
||||
model.onRatingSelected(4)
|
||||
model.feedbackContent!!.onSubmit()
|
||||
assertThat(model.ratingState).isEqualTo(RatingUM.RatingState.AlreadyRated(rating = 4))
|
||||
assertThat(model.state.value.feedbackBottomSheet.isShown).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onSubmit resets isSubmitting on failure`() = runTest {
|
||||
val model = buildModel(
|
||||
onLoadRating = { null },
|
||||
onSubmitRating = { _, _ -> error("network error") },
|
||||
)
|
||||
model.onRatingSelected(3)
|
||||
model.feedbackContent!!.onSubmit()
|
||||
assertThat(model.feedbackContent!!.isSubmitting).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onSubmit shows snackbar on failure`() = runTest {
|
||||
val model = buildModel(
|
||||
onLoadRating = { null },
|
||||
onSubmitRating = { _, _ -> error("network error") },
|
||||
)
|
||||
model.onRatingSelected(3)
|
||||
model.feedbackContent!!.onSubmit()
|
||||
verify(exactly = 1) { uiMessageSender.send(ofType<SnackbarMessage>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onSubmit is no-op when no rating selected`() = runTest {
|
||||
val submitMock: suspend (Int, String) -> Unit = mockk(relaxed = true)
|
||||
val model = buildModel(onLoadRating = { null }, onSubmitRating = submitMock)
|
||||
// open BS without selecting rating (edge case - shouldn't happen in practice)
|
||||
// just verify submit does nothing without a selected rating
|
||||
coVerify(exactly = 0) { submitMock(any(), any()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -5,4 +5,5 @@ interface SwapFeatureToggles {
|
|||
val isSwapIntegratedApproveEnabled: Boolean
|
||||
val isSwapAbEnabled: Boolean
|
||||
val isSwapProviderFilterEnabled: Boolean
|
||||
val isSwapRateExperienceEnabled: Boolean
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ dependencies {
|
|||
|
||||
/** Network */
|
||||
implementation(deps.retrofit)
|
||||
implementation(deps.retrofit.moshi)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
implementation(deps.arrow.core)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
package com.tangem.feature.swap
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.datasource.api.surveysparrow.SurveySparrowApi
|
||||
import com.tangem.datasource.api.surveysparrow.models.CreateSurveySparrowResponseBody
|
||||
import com.tangem.datasource.api.surveysparrow.models.SurveySparrowAnswerDto
|
||||
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
|
||||
import com.tangem.feature.swap.domain.models.domain.ExistingRating
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams
|
||||
import org.json.JSONObject
|
||||
|
||||
internal class DefaultSwapFeedbackRepository(
|
||||
private val api: SurveySparrowApi,
|
||||
private val surveyId: Long,
|
||||
private val ratingQuestionId: Long,
|
||||
private val feedbackQuestionId: Long,
|
||||
) : SwapFeedbackRepository {
|
||||
|
||||
override suspend fun getRating(txExternalId: String): Either<Throwable, ExistingRating?> {
|
||||
return Either.catch {
|
||||
val responses = api.getResponses(
|
||||
surveyId = surveyId,
|
||||
variables = JSONObject().put("tx_external_id", txExternalId).toString(),
|
||||
limit = 1,
|
||||
)
|
||||
val ratingAnswer = responses.data
|
||||
.firstOrNull()
|
||||
?.answers
|
||||
?.firstOrNull { answer ->
|
||||
when (val id = answer.questionId) {
|
||||
is Number -> id.toLong() == ratingQuestionId
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
?.answer
|
||||
?.let { v ->
|
||||
when (v) {
|
||||
is Number -> v.toInt()
|
||||
is String -> v.toIntOrNull()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
if (ratingAnswer != null) ExistingRating(ratingAnswer) else null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun submitFeedback(params: SwapFeedbackParams): Either<Throwable, Unit> {
|
||||
return Either.catch {
|
||||
api.createResponse(
|
||||
CreateSurveySparrowResponseBody(
|
||||
surveyId = surveyId,
|
||||
answers = buildList {
|
||||
add(SurveySparrowAnswerDto(ratingQuestionId, params.rating.toString()))
|
||||
if (params.feedback.isNotEmpty()) {
|
||||
add(SurveySparrowAnswerDto(feedbackQuestionId, params.feedback))
|
||||
}
|
||||
},
|
||||
variables = mapOf(
|
||||
"tx_external_id" to params.txExternalId,
|
||||
"provider_name" to params.providerName,
|
||||
"tx_url" to params.txUrl,
|
||||
"user_wallet_id" to params.userWalletIdHash,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.feature.swap
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.right
|
||||
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
|
||||
import com.tangem.feature.swap.domain.models.domain.ExistingRating
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams
|
||||
|
||||
internal class NoOpSwapFeedbackRepository : SwapFeedbackRepository {
|
||||
|
||||
override suspend fun getRating(txExternalId: String): Either<Throwable, ExistingRating?> = null.right()
|
||||
|
||||
override suspend fun submitFeedback(params: SwapFeedbackParams): Either<Throwable, Unit> = Unit.right()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.feature.swap.domain
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
|
||||
import com.tangem.feature.swap.domain.models.domain.ExistingRating
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams
|
||||
import javax.inject.Inject
|
||||
|
||||
class SwapFeedbackUseCase @Inject constructor(
|
||||
private val repository: SwapFeedbackRepository,
|
||||
) {
|
||||
suspend fun getExistingRating(txExternalId: String): Either<Throwable, ExistingRating?> =
|
||||
repository.getRating(txExternalId)
|
||||
|
||||
suspend fun submit(params: SwapFeedbackParams): Either<Throwable, Unit> = repository.submitFeedback(params)
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.tangem.feature.swap.domain.api
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.feature.swap.domain.models.domain.ExistingRating
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams
|
||||
|
||||
interface SwapFeedbackRepository {
|
||||
suspend fun getRating(txExternalId: String): Either<Throwable, ExistingRating?>
|
||||
suspend fun submitFeedback(params: SwapFeedbackParams): Either<Throwable, Unit>
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
package com.tangem.feature.swap.domain.models.domain
|
||||
|
||||
data class ExistingRating(val rating: Int)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -168,7 +168,7 @@ internal fun TangemPayDetailsScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
expressTransactionsBottomSheetState?.content()
|
||||
expressTransactionsBottomSheetState?.content(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ interface ExpressTransactionsComponent {
|
|||
data class Params(
|
||||
val userWalletId: UserWalletId,
|
||||
val currency: CryptoCurrency,
|
||||
val onRatingRequested: (
|
||||
(txExternalId: String, providerName: String, txExternalUrl: String, userWalletIdStringValue: String) -> Unit
|
||||
)? = null,
|
||||
val onRatingDismiss: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
interface Factory : ComponentFactory<Params, ExpressTransactionsComponent>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,18 @@ import com.arkivanov.decompose.router.slot.dismiss
|
|||
import com.tangem.blockchain.common.address.AddressType
|
||||
import com.tangem.domain.dynamicaddresses.IsDynamicAddressesAvailableUseCase
|
||||
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
|
||||
import com.tangem.common.extensions.calculateSha256
|
||||
import com.tangem.common.extensions.hexToBytes
|
||||
import com.tangem.common.extensions.toHexString
|
||||
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.common.ui.bottomsheet.receive.AddressModel
|
||||
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
|
||||
import com.tangem.features.rating.RatingComponent
|
||||
import com.tangem.feature.swap.domain.SwapFeedbackUseCase
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
|
||||
|
|
@ -183,6 +190,8 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
|
||||
private val designFeatureToggles: DesignFeatureToggles,
|
||||
private val redesignStateController: TokenDetailsStateController,
|
||||
private val swapFeedbackUseCase: SwapFeedbackUseCase,
|
||||
private val swapFeatureToggles: SwapFeatureToggles,
|
||||
) : Model(),
|
||||
TokenDetailsClickIntents,
|
||||
YieldSupplyDepositedWarningComponent.ModelCallback {
|
||||
|
|
@ -209,6 +218,7 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
private var isBalanceLoadedEventSent = false
|
||||
|
||||
val bottomSheetNavigation: SlotNavigation<TokenDetailsBottomSheetConfig> = SlotNavigation()
|
||||
val ratingSlotNavigation = SlotNavigation<RatingComponent.Params>()
|
||||
|
||||
private val stateFactory = TokenDetailsStateFactory(
|
||||
currentStateProvider = Provider { uiState.value },
|
||||
|
|
@ -907,6 +917,7 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
state.copy(pullToRefreshConfig = state.pullToRefreshConfig.copy(isRefreshing = false))
|
||||
}
|
||||
}.saveIn(refreshStateJobHolder)
|
||||
ratingSlotNavigation.dismiss()
|
||||
}
|
||||
|
||||
override fun onCloseRentInfoNotification() {
|
||||
|
|
@ -1079,6 +1090,37 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
uiState.value = stateFactory.getStateWithUpdatedBalanceSegmentedButtonConfig(config)
|
||||
}
|
||||
|
||||
fun activateRatingForExpressTx(
|
||||
txExternalId: String,
|
||||
providerName: String,
|
||||
txExternalUrl: String,
|
||||
userWalletIdStringValue: String,
|
||||
) {
|
||||
if (!swapFeatureToggles.isSwapRateExperienceEnabled) return
|
||||
ratingSlotNavigation.activate(
|
||||
RatingComponent.Params(
|
||||
onLoadRating = {
|
||||
swapFeedbackUseCase.getExistingRating(txExternalId)
|
||||
.fold(ifLeft = { null }, ifRight = { it?.rating })
|
||||
},
|
||||
onSubmitRating = { rating, feedback ->
|
||||
swapFeedbackUseCase.submit(
|
||||
SwapFeedbackParams(
|
||||
userWalletIdHash = userWalletIdStringValue.hexToBytes()
|
||||
.calculateSha256()
|
||||
.toHexString(),
|
||||
providerName = providerName,
|
||||
txUrl = txExternalUrl,
|
||||
txExternalId = txExternalId,
|
||||
rating = rating,
|
||||
feedback = feedback,
|
||||
),
|
||||
).onLeft { TangemLogger.e("Failed to submit swap feedback: $it") }
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onYieldInfoClick() {
|
||||
analyticsEventsHandler.send(
|
||||
YieldSupplyAnalytics.EarnedFundsInfo(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ internal fun TokenDetailsScreen(
|
|||
)
|
||||
}
|
||||
|
||||
expressState.bottomSheetSlot?.content()
|
||||
expressState.bottomSheetSlot?.content(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue