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()) }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue