Updated on 2026-08-14

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,33 @@
package com.tangem.feature.rating
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.feature.rating.model.RatingModel
import com.tangem.feature.rating.ui.RatingBlock
import com.tangem.features.rating.RatingComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultRatingComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: RatingComponent.Params,
) : RatingComponent, AppComponentContext by appComponentContext {
private val model: RatingModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
RatingBlock(state = state, modifier = modifier)
}
@AssistedFactory
interface Factory : RatingComponent.Factory {
override fun create(context: AppComponentContext, params: RatingComponent.Params): DefaultRatingComponent
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.feature.rating.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.feature.rating.DefaultRatingComponent
import com.tangem.feature.rating.model.RatingModel
import com.tangem.features.rating.RatingComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
internal interface RatingFeatureModule {
@Binds
@Singleton
fun bindFactory(factory: DefaultRatingComponent.Factory): RatingComponent.Factory
}
@Module
@InstallIn(ModelComponent::class)
internal interface RatingModelModule {
@Binds
@IntoMap
@ClassKey(RatingModel::class)
fun bindModel(model: RatingModel): Model
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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