diff --git a/.claude/skills/write-ui-test/SKILL.md b/.claude/skills/write-ui-test/SKILL.md index 86bf3b1cd0..acb6f6d5da 100644 --- a/.claude/skills/write-ui-test/SKILL.md +++ b/.claude/skills/write-ui-test/SKILL.md @@ -139,8 +139,10 @@ Delete anything explaining WHAT a step does. ## Reference docs - **`reference/compose-traps.md`** — read when the screen uses `PullToRefreshBox`, - `TangemHoldToConfirmButton`, a Decompose model that fetches in `init {}`, or a hot-wallet import with - an access code. These have silent failure modes that look like passing tests. + `TangemHoldToConfirmButton`, a Decompose model that fetches in `init {}`, a hot-wallet import with + an access code, or a target inside a **LazyColumn/LazyRow that may be below the fold** (use a + `KLazyListNode` matcher that auto-scrolls — never a manual swipe). These have silent failure modes that + look like passing tests. - **`reference/running-and-debugging.md`** — read when building, installing, running tests (orchestrator vs. raw `am instrument`), running against a local WireMock, interpreting CLI/Allure output, using `@Ignore`, or driving WireMock scenarios. \ No newline at end of file diff --git a/.claude/skills/write-ui-test/reference/compose-traps.md b/.claude/skills/write-ui-test/reference/compose-traps.md index edb1662a86..0458caa898 100644 --- a/.claude/skills/write-ui-test/reference/compose-traps.md +++ b/.claude/skills/write-ui-test/reference/compose-traps.md @@ -95,6 +95,65 @@ fun walletNameValue(name: String) = child { withText(name); useUnmergedTree = tr fun walletNameValue(name: String) = child { hasText(name); useUnmergedTree = true } ``` +## LazyList item below the fold: plain `child { }` finds it but can't click it + +A `child { hasTestTag(ITEM); hasAnyDescendant(withText(name)) }` matcher resolves the semantics node +even when the item is composed **off-screen** (LazyColumn keeps a few items past the viewport). But the +node isn't displayed, so `clickWithAssertion()` (`assertIsDisplayed()` first) fails, or `performClick()` +taps nothing. Symptom: the test passes when the item happens to be near the top and fails for items +lower in the list — and a manual swipe "fixes" it. Do **not** patch with a swipe (flaky, the +`clickableSingle` 500ms debounce can also eat fast programmatic clicks). + +**Whenever a target lives in a LazyColumn/LazyRow and might be below the fold, build a `KLazyListNode` +matcher up front** — `childWith` scrolls the list to the item before returning it: + +```kotlin +import com.tangem.common.utils.LazyListItemNode +import com.tangem.core.ui.utils.LazyListItemPositionSemantics +import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode + +private val tokensList = KLazyListNode( + semanticsProvider = semanticsProvider, // primary-ctor param is in scope in initializers + viewBuilderAction = { hasTestTag(SomeScreenTestTags.LAZY_LIST) }, // the LazyColumn's OWN tag + itemTypeBuilder = { itemType(::LazyListItemNode) }, + positionMatcher = { position -> SemanticsMatcher.expectValue(LazyListItemPositionSemantics, position) }, +) + +@OptIn(ExperimentalTestApi::class) +fun tokenWithTitle(title: String): LazyListItemNode = + tokensList.childWith { + hasTestTag(SomeScreenTestTags.LAZY_LIST_ITEM) + hasText(title) + useUnmergedTree = true + } +``` + +Non-obvious points that bite: + +- **`childWith` searches the MERGED tree** (it scopes via the list's `viewBuilderAction`, whose + `useUnmergedTree` defaults to `false`). So match the item by `hasText(title)` — on a `MergeDescendants` + item the child texts aggregate onto the item node. `hasAnyDescendant(withText(...))` does **not** match + there. (`useUnmergedTree = true` on the item matcher is inert for the scroll/filter but harmless; keep + it to mirror existing page objects.) +- **The list needs its OWN `testTag` on the `LazyColumn`.** If production tags only the *items* (e.g. + `MARKETS_TOKENS_LIST_ITEM`) and not the container, add a tag to the `LazyColumn` modifier in the + production composable. Reuse the screen's existing `…TestTags.LAZY_LIST` constant when one fits. +- **Scope to the right list when several coexist.** Multiple LazyColumns with the same *item* tag can be + composed at once (e.g. the Add-Funds `ChooseTokenScreen` list AND the main-screen markets sheet, both + using `MARKETS_TOKENS_LIST_ITEM`). A bare top-level `child { hasTestTag(ITEM); … }` is then ambiguous + and may match the wrong screen. `childWith` (and `tokensList.child { … }`) scope through the container + tag via `onNode(LAZY_LIST)` / `hasAnyAncestor(LAZY_LIST)`, so they pick the intended list. Prefer a + unique container tag over hoping the item text is unique. +- **`childWith` returns a `LazyListItemNode`, not a `KNode`.** `clickWithAssertion()` was a `KNode` + extension; it's been generalized to `fun BaseNode<*>.clickWithAssertion()` (in + `common/extensions/KNode.kt`) so it works on both. Both types extend `BaseNode`, and + `assertIsDisplayed()`/`performClick()` live on `BaseNode`. +- `positionMatcher` is only used by `childAt(index)` / `hasLazyListItemPosition`. For `childWith` + (match-by-content) the items don't need to expose `LazyListItemPositionSemantics` — pass the matcher + anyway since the constructor requires it. + +Reference: `AddFundsBottomSheetPageObject.trendingTokenWithTitle` and `MainScreenPageObject` (`lazyList`). + ## Decompose model lifecycle vs. data refresh Models (e.g. `TangemPayDetailsModel`) call data fetches from `init {}`, NOT on `ON_RESUME`. Returning diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt index ee98f114ff..04c925b312 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt @@ -4,9 +4,10 @@ import android.os.SystemClock import androidx.compose.ui.test.ComposeTimeoutException import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeTestRule +import io.github.kakaocup.compose.node.core.BaseNode import io.github.kakaocup.compose.node.element.KNode -fun KNode.clickWithAssertion() { +fun BaseNode<*>.clickWithAssertion() { assertIsDisplayed() performClick() } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt index e2e2b20eb2..abb099d1a1 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt @@ -1,16 +1,21 @@ package com.tangem.screens +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase +import com.tangem.common.utils.LazyListItemNode import com.tangem.core.ui.R import com.tangem.core.ui.test.BaseBottomSheetTestTags import com.tangem.core.ui.test.BuyTokenScreenTestTags import com.tangem.core.ui.test.MarketsTestTags import com.tangem.core.ui.test.TokenActionsTestTags import com.tangem.core.ui.test.TokenElementsTestTags +import com.tangem.core.ui.utils.LazyListItemPositionSemantics import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.kakao.common.utilities.getResourceString import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText @@ -53,11 +58,22 @@ class AddFundsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractions useUnmergedTree = true } - fun trendingTokenWithTitle(tokenTitle: String): KNode = child { - hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM) - hasAnyDescendant(withText(tokenTitle)) - useUnmergedTree = true - } + private val trendingTokensList = KLazyListNode( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(BuyTokenScreenTestTags.LAZY_LIST) }, + itemTypeBuilder = { itemType(::LazyListItemNode) }, + positionMatcher = { position -> + SemanticsMatcher.expectValue(LazyListItemPositionSemantics, position) + }, + ) + + @OptIn(ExperimentalTestApi::class) + fun trendingTokenWithTitle(tokenTitle: String): LazyListItemNode = + trendingTokensList.childWith { + hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM) + hasText(tokenTitle) + useUnmergedTree = true + } } internal fun BaseTestCase.onAddFundsBottomSheet(function: AddFundsBottomSheetPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt index 3d7345f883..e484f38fd4 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt @@ -54,13 +54,22 @@ class AppCurrencyTest : BaseTestCase() { step("Click on currency '$targetCurrency'") { onAppCurrencySelectorScreen { currencyItem(targetCurrency).performClick() } } - step("Press 'Back' button to return to 'Details' screen") { + step("Assert 'App settings' screen is open after currency selection") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onAppSettingsScreen { currencyButton.assertIsDisplayed() } + } + } + step("Return to 'Details' screen") { waitForIdle() device.uiDevice.pressBack() } - step("Press 'Back' button to return to 'Main' screen") { - waitForIdle() - device.uiDevice.pressBack() + step("Return to 'Main' screen via 'Back' button") { + onDetailsScreen { topAppBarBackButton.clickWithAssertion() } + } + step("Assert 'Main' screen is opened") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onMainScreen { screenContainer.assertIsDisplayed() } + } } step("Assert total balance contains '$targetSymbol' on 'Main' screen") { // Balance re-loads in the new currency async after the switch — wait for the € equivalent. diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt index 1ef7c4baf1..029a430d70 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -501,7 +501,9 @@ class MainScreenActionButtonsTest : BaseTestCase() { onMainScreen { swapButton.performClick() } } step("Check 'Action is unavailable' dialog") { - checkActionIsUnavailableDialog() + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkActionIsUnavailableDialog() + } } step("Click on 'Ok' button") { onDialog { okButton.performClick() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt index fb4483da57..c2278600d3 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt @@ -3,11 +3,13 @@ package com.tangem.tests.send.warnings import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.XLM_ACTIVATED_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.XLM_NON_ACTIVATED_RECIPIENT_ADDRESS import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendConfirmScreenViaNextButton import com.tangem.scenarios.openSendScreen import com.tangem.screens.onSendAddressScreen import com.tangem.screens.onSendConfirmScreen @@ -110,8 +112,10 @@ class StellarWarningsTest : BaseTestCase() { step("Type non activated address in input text field") { onSendAddressScreen { addressTextField.performTextReplacement(XLM_NON_ACTIVATED_RECIPIENT_ADDRESS) } } - step("Click on 'Next' button") { - onSendAddressScreen { nextButton.clickWithAssertion() } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } } step("Assert 'Invalid reserve amount warning' is not displayed") { checkSendWarning( diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index a9cc20669f..4446e6ab40 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -129,7 +129,7 @@ }, { "name": "AND_15482_SURVEYSPARROW_ENABLED", - "version": "6.0" + "version": "undefined" }, { "name": "AND_15258_QUICK_TOP_UP_ENABLED", diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt b/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt index a3438a1798..ccde33ce2b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt @@ -2,11 +2,9 @@ package com.tangem.core.ui.components.haze import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.tangem.core.ui.res.LocalHazeState -import com.tangem.core.ui.res.LocalPowerSavingState import com.tangem.core.ui.res.LocalRootBackgroundColor import dev.chrisbanes.haze.* import dev.chrisbanes.haze.materials.CupertinoMaterials @@ -25,32 +23,19 @@ fun ProvideHaze(content: @Composable () -> Unit) { } /** - * Returns whether the haze blur effect would actually render for the given [state], taking both - * the global [HazeState.blurEnabled] flag and the device's power-saving mode into account. - * - * Callers that pass a fully-transparent fallback to [hazeEffectTangem] should use this to decide - * whether they need to render an opaque fallback layer themselves — otherwise the surface can - * become invisible whenever blur is disabled (e.g. while power-saving mode is on). - */ -@Composable -fun isHazeBlurEffectivelyEnabled(state: HazeState = LocalHazeState.current): Boolean { - val isPowerSavingEnabled by LocalPowerSavingState.current.isPowerSavingModeEnabled.collectAsState() - return state.blurEnabled && !isPowerSavingEnabled -} - -/** - * Applies a haze effect to the [Modifier] with consideration of global haze settings and power saving mode. + * Applies a haze effect to the [Modifier] with consideration of global haze settings. * * @param configure A lambda to configure the [HazeEffectScope]. * @return A [Modifier] with the configured haze effect applied. */ +@OptIn(ExperimentalHazeMaterialsApi::class) @Composable fun Modifier.hazeEffectTangem( state: HazeState = LocalHazeState.current, style: HazeStyle = CupertinoMaterials.ultraThin(), configure: HazeEffectScope.() -> Unit = {}, ): Modifier { - val isGlobalBlurEnabled = isHazeBlurEffectivelyEnabled(state) + val isGlobalBlurEnabled = state.blurEnabled val rootBackground by LocalRootBackgroundColor.current return hazeEffect(state, style) { @@ -66,25 +51,19 @@ fun Modifier.hazeEffectTangem( * * @param style The [HazeStyle] to apply. Defaults to [HazeStyle.Unspecified]. * @param isBlurEnabled A Boolean indicating whether blur is enabled. Defaults to true. - * @param reactToPowerSavingMode A Boolean indicating whether the haze effect should react to power saving mode. - * Defaults to false. * @param configure A lambda to configure the [HazeEffectScope]. * @return A [Modifier] with the configured haze foreground effect applied. */ @Composable fun Modifier.hazeForegroundEffectTangem( style: HazeStyle = HazeStyle.Unspecified, - reactToPowerSavingMode: Boolean = false, isBlurEnabled: Boolean = true, configure: HazeEffectScope.() -> Unit = {}, ): Modifier { - val powerSavingEnabled = LocalPowerSavingState.current.isPowerSavingModeEnabled.collectAsState() - val isGlobalBlurEnabled = isBlurEnabled && (!reactToPowerSavingMode || !powerSavingEnabled.value) - return hazeEffect( style = style, ) { - blurEnabled = isGlobalBlurEnabled + blurEnabled = isBlurEnabled configure() } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/contextmenu/TangemContextMenu.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/contextmenu/TangemContextMenu.kt index 3800173371..0eff3172fb 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/contextmenu/TangemContextMenu.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/contextmenu/TangemContextMenu.kt @@ -74,8 +74,9 @@ fun TangemContextMenu( } } -private const val IN_TRANSITION_DURATION = 120 private const val OUT_TRANSITION_DURATION = 75 +private const val ENTER_SPRING_DAMPING = 0.82f +private const val ENTER_SPRING_STIFFNESS = 1100f @Suppress("ReusedModifierInstance", "MagicNumber") @Composable @@ -91,10 +92,10 @@ private fun DropdownMenuContent( val scale by transition.animateFloat( transitionSpec = { if (false isTransitioningTo true) { - // Dismissed to expanded - tween( - durationMillis = IN_TRANSITION_DURATION, - easing = LinearOutSlowInEasing, + // Dismissed to expanded — springy iOS-like pop scaling up from the anchor. + spring( + dampingRatio = ENTER_SPRING_DAMPING, + stiffness = ENTER_SPRING_STIFFNESS, ) } else { // Expanded to dismissed. diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/contextmenu/TangemContextMenuCheckboxItem.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/contextmenu/TangemContextMenuCheckboxItem.kt index 8cb5703384..8e6e4710a1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/contextmenu/TangemContextMenuCheckboxItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/contextmenu/TangemContextMenuCheckboxItem.kt @@ -3,6 +3,7 @@ package com.tangem.core.ui.ds.contextmenu import androidx.compose.foundation.layout.* import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.tangem.core.ui.ds.checkbox.TangemCheckbox @@ -18,12 +19,13 @@ import com.tangem.core.ui.res.TangemTheme fun TangemContextMenuCheckboxItem(title: TextReference, isChecked: Boolean, onClick: () -> Unit) { Row( horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .width(238.dp) .clickableSingle(onClick = onClick) .padding( - vertical = TangemTheme.dimens2.x5, + vertical = TangemTheme.dimens2.x2_5, horizontal = TangemTheme.dimens2.x4, ), ) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/collapsing/WalletBalanceExitUntilCollapsedScrollBehavior.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/collapsing/WalletBalanceExitUntilCollapsedScrollBehavior.kt index 6ac6a4743f..99f7321253 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/collapsing/WalletBalanceExitUntilCollapsedScrollBehavior.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/topbar/collapsing/WalletBalanceExitUntilCollapsedScrollBehavior.kt @@ -7,7 +7,9 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.ScrollScope import androidx.compose.foundation.gestures.scrollable import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -19,7 +21,10 @@ import androidx.compose.ui.unit.dp import com.tangem.core.ui.ds.topbar.collapsing.entity.TangemCollapsingAppBarState import com.tangem.core.ui.ds.topbar.collapsing.entity.TopBarScrollDirection import com.tangem.core.ui.ds.topbar.collapsing.entity.rememberTangemCollapsingAppBarState +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.utils.toPx +import kotlinx.coroutines.flow.drop import kotlin.math.abs import kotlin.math.absoluteValue @@ -48,6 +53,9 @@ fun rememberTangemExitUntilCollapsedScrollBehavior( partialHeightLimit = partialCollapsedHeight.toPx(), isTopOverscrollEnabled = isTopOverscrollEnabled, ) + + SnapThresholdHapticEffect(state = topBarState) + return exitUntilCollapsedScrollBehavior( state = topBarState, snapAnimationSpec = snapAnimationSpec, @@ -55,6 +63,35 @@ fun rememberTangemExitUntilCollapsedScrollBehavior( ) } +/** + * Provides a detent-style haptic feedback as the collapsing app bar crosses the threshold between its + * expanded and collapsed snap states + * + * @param state The state of the collapsing app bar to observe. + * @param snapThreshold The collapsed fraction (0f..1f) at which the haptic detent fires. Default is 0.5f, + * the midpoint between the collapse and expand snap thresholds. + */ +@Composable +private fun SnapThresholdHapticEffect( + state: TangemCollapsingAppBarState, + @Suppress("MagicNumber") snapThreshold: Float = 0.5f, +) { + val hapticManager = LocalHapticManager.current + LaunchedEffect(state, hapticManager, snapThreshold) { + snapshotFlow { state.collapsedFraction >= snapThreshold } + .drop(1) // skip the initial value so we only react to actual crossings + .collect { isPastThreshold -> + hapticManager.perform( + if (isPastThreshold) { + TangemHapticEffect.View.GestureThresholdActivate + } else { + TangemHapticEffect.View.GestureThresholdDeactivate + }, + ) + } + } +} + /** * A scroll behavior for a collapsing top app bar that collapses when scrolling up and expands when scrolling down. * When the user stops scrolling, the app bar will settle to either fully collapsed or fully expanded state diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt index 8cc5f9c678..c62c54918d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt @@ -18,9 +18,9 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.haze.hazeEffectTangem -import com.tangem.core.ui.components.haze.isHazeBlurEffectivelyEnabled import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.extensions.softLayerShadow +import com.tangem.core.ui.res.LocalHazeState import com.tangem.core.ui.res.TangemTheme import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.HazeTint @@ -134,14 +134,13 @@ private fun Modifier.materialBorder(shape: Shape): Modifier = border( * When the haze state is enabled, paints a haze-blurred backdrop. When disabled, layers two * solid colors so the result still reads as "tinted fill" instead of going transparent. * - * Uses [isHazeBlurEffectivelyEnabled] (rather than [LocalHazeState]'s `blurEnabled` directly) so - * the solid fallback is also applied when blur is suppressed for reasons other than the haze flag - * — most notably when the device is in power-saving mode. Otherwise the haze modifier's - * `fallbackTint = HazeTint(Color.Transparent)` would leave the surface fully transparent. + * Reads [LocalHazeState]'s `blurEnabled` so the solid fallback is applied whenever blur is off — + * otherwise the haze modifier's `fallbackTint = HazeTint(Color.Transparent)` would leave the + * surface fully transparent. */ @Composable private fun Modifier.materialFill(): Modifier { - val isBlurEnabled = isHazeBlurEffectivelyEnabled() + val isBlurEnabled = LocalHazeState.current.blurEnabled val hazed = hazeEffectTangem( style = HazeStyle( backgroundColor = TangemTheme.colors3.material.fill.blur, diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt index 74b5f7146c..9249b48b89 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt @@ -25,7 +25,7 @@ internal class EmailSubjectResolver(private val resources: Resources) { resources.getStringSafe(R.string.feedback_subject_support_tangem) } } - is FeedbackEmailType.BackupProblem -> resources.getStringSafe(R.string.feedback_subject_backup_problem) + is FeedbackEmailType.BackupProblem -> resources.getStringSafe(R.string.common_backup_error) is FeedbackEmailType.RateCanBeBetter -> resources.getStringSafe(R.string.feedback_subject_rate_negative) is FeedbackEmailType.ScanningProblem -> resources.getStringSafe(R.string.feedback_subject_scan_failed) is FeedbackEmailType.TransactionSendingProblem, diff --git a/domain/search/build.gradle.kts b/domain/search/build.gradle.kts index e13e688d24..8c9eced03c 100644 --- a/domain/search/build.gradle.kts +++ b/domain/search/build.gradle.kts @@ -17,4 +17,7 @@ dependencies { implementation(projects.domain.appCurrency) implementation(projects.domain.account) implementation(projects.domain.account.status) + + testImplementation(projects.common.test) + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt b/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt index ba5da04383..c84ebc3d44 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt +++ b/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt @@ -4,11 +4,11 @@ import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.models.portfolio.UserAssetEntry import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.search.model.SearchResult -import com.tangem.domain.models.portfolio.UserAssetEntry import com.tangem.domain.search.model.UserAssetSearchItem import com.tangem.domain.search.repository.SearchRepository import kotlinx.coroutines.flow.Flow @@ -101,7 +101,7 @@ class GetSearchResultsUseCase( if (!shouldGroup) { return entries .map { UserAssetSearchItem.Single(it) } - .sortedByDescending { it.entry.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + .sortedByDescending { it.fiatBalance() } } val grouped = entries.groupBy { entry -> @@ -109,20 +109,27 @@ class GetSearchResultsUseCase( rawId?.value ?: "${entry.currencyStatus.currency.name}|${entry.currencyStatus.currency.symbol}" } - return grouped.map { (_, groupEntries) -> - val assetInfo = groupEntries.first() - UserAssetSearchItem.Grouped( - tokenName = assetInfo.currencyStatus.currency.name, - tokenSymbol = assetInfo.currencyStatus.currency.symbol, - tokenIconUrl = assetInfo.currencyStatus.currency.iconUrl, - entries = groupEntries, - ) - }.sortedByDescending { item -> - when (item) { - is UserAssetSearchItem.Grouped -> - item.entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + return grouped + .map { (_, groupEntries) -> + // Only assets with several matching instances are merged; a single instance stays Single. + if (groupEntries.size > 1) { + val assetInfo = groupEntries.first() + UserAssetSearchItem.Grouped( + tokenName = assetInfo.currencyStatus.currency.name, + tokenSymbol = assetInfo.currencyStatus.currency.symbol, + tokenIconUrl = assetInfo.currencyStatus.currency.iconUrl, + entries = groupEntries, + ) + } else { + UserAssetSearchItem.Single(groupEntries.first()) + } } - } + .sortedByDescending { it.fiatBalance() } + } + + private fun UserAssetSearchItem.fiatBalance(): BigDecimal = when (this) { + is UserAssetSearchItem.Single -> entry.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO + is UserAssetSearchItem.Grouped -> entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } } private fun extractMatchingAssets( diff --git a/domain/search/src/test/kotlin/com/tangem/domain/search/usecase/GetSearchResultsUseCaseTest.kt b/domain/search/src/test/kotlin/com/tangem/domain/search/usecase/GetSearchResultsUseCaseTest.kt new file mode 100644 index 0000000000..79f70a2314 --- /dev/null +++ b/domain/search/src/test/kotlin/com/tangem/domain/search/usecase/GetSearchResultsUseCaseTest.kt @@ -0,0 +1,449 @@ +package com.tangem.domain.search.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.* +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.portfolio.UserAssetEntry +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.search.model.RecentSearchToken +import com.tangem.domain.search.model.SearchResult +import com.tangem.domain.search.model.SearchTextHint +import com.tangem.domain.search.model.UserAssetSearchItem +import com.tangem.domain.search.repository.SearchRepository +import com.tangem.test.core.getEmittedValues +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class GetSearchResultsUseCaseTest { + + private val searchRepository = mockk() + private val multiAccountStatusListSupplier = mockk() + private val userWalletsListRepository = mockk() + + private val useCase = GetSearchResultsUseCase( + searchRepository = searchRepository, + multiAccountStatusListSupplier = multiAccountStatusListSupplier, + userWalletsListRepository = userWalletsListRepository, + ) + + private val factory = MockCryptoCurrencyFactory() + private val cardano = factory.cardano + private val chia = factory.chia + private val ethereum = factory.ethereum + + private val walletId1 = UserWalletId("011") + private val walletId2 = UserWalletId("022") + + @BeforeEach + fun setUp() { + clearMocks(searchRepository, multiAccountStatusListSupplier, userWalletsListRepository) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class History { + + @Test + fun `GIVEN blank query WHEN invoke THEN returns history with empty user assets`() = runTest { + // Arrange + val hints = listOf(SearchTextHint(text = "eth", timestamp = 1L)) + val tokens = listOf() + every { searchRepository.getTextHints() } returns flowOf(hints) + every { searchRepository.getRecentTokens() } returns flowOf(tokens) + + // Act + val actual = getEmittedValues(useCase(query = " ")) + + // Assert + val expected = SearchResult(textHints = hints, recentTokens = tokens, userAssets = emptyList()) + assertThat(actual).containsExactly(expected) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class NoGrouping { + + @Test + fun `GIVEN single wallet single account WHEN search THEN items are Single sorted by fiat desc`() = runTest { + // Arrange + val cardanoStatus = statusOf(cardano, fiatAmount = BigDecimal(10)) + val chiaStatus = statusOf(chia, fiatAmount = BigDecimal(20)) + val mainAccount = mainAccount(walletId1) + val wallet = wallet(walletId1) + + stub( + wallets = listOf(wallet), + statusLists = listOf( + statusList(walletId1, cryptoPortfolio(mainAccount, listOf(cardanoStatus, chiaStatus))), + ), + ) + + // Act + val actual = search(query = "c") + + // Assert — single wallet + single account: nothing is grouped, sorted by fiat desc + assertThat(actual).containsExactly( + UserAssetSearchItem.Single(entry(wallet, mainAccount, chiaStatus)), + UserAssetSearchItem.Single(entry(wallet, mainAccount, cardanoStatus)), + ).inOrder() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Grouping { + + @Test + fun `GIVEN several wallets but each asset appears once WHEN search THEN every asset stays Single`() = runTest { + // Arrange — the regression: a unique asset must not become a Grouped item just because several wallets exist + val cardanoStatus = statusOf(cardano, fiatAmount = BigDecimal(10)) + val chiaStatus = statusOf(chia, fiatAmount = BigDecimal(20)) + val mainAccount1 = mainAccount(walletId1) + val mainAccount2 = mainAccount(walletId2) + val firstWallet = wallet(walletId1) + val secondWallet = wallet(walletId2) + + stub( + wallets = listOf(firstWallet, secondWallet), + statusLists = listOf( + statusList(walletId1, cryptoPortfolio(mainAccount1, listOf(cardanoStatus))), + statusList(walletId2, cryptoPortfolio(mainAccount2, listOf(chiaStatus))), + ), + ) + + // Act + val actual = search(query = "c") + + // Assert + assertThat(actual).containsExactly( + UserAssetSearchItem.Single(entry(secondWallet, mainAccount2, chiaStatus)), + UserAssetSearchItem.Single(entry(firstWallet, mainAccount1, cardanoStatus)), + ).inOrder() + } + + @Test + fun `GIVEN same currency in two wallets WHEN search THEN it is Grouped and unique asset stays Single`() = + runTest { + // Arrange + val chiaStatusW1 = statusOf(chia, fiatAmount = BigDecimal(15)) + val chiaStatusW2 = statusOf(chia, fiatAmount = BigDecimal(5)) + val cardanoStatus = statusOf(cardano, fiatAmount = BigDecimal(30)) + val mainAccount1 = mainAccount(walletId1) + val mainAccount2 = mainAccount(walletId2) + val firstWallet = wallet(walletId1) + val secondWallet = wallet(walletId2) + + stub( + wallets = listOf(firstWallet, secondWallet), + statusLists = listOf( + statusList(walletId1, cryptoPortfolio(mainAccount1, listOf(chiaStatusW1, cardanoStatus))), + statusList(walletId2, cryptoPortfolio(mainAccount2, listOf(chiaStatusW2))), + ), + ) + + // Act + val actual = search(query = "c") + + // Assert — cardano (30) outranks the chia group (15 + 5 = 20) + assertThat(actual).containsExactly( + UserAssetSearchItem.Single(entry(firstWallet, mainAccount1, cardanoStatus)), + UserAssetSearchItem.Grouped( + tokenName = chia.name, + tokenSymbol = chia.symbol, + tokenIconUrl = chia.iconUrl, + entries = listOf( + entry(firstWallet, mainAccount1, chiaStatusW1), + entry(secondWallet, mainAccount2, chiaStatusW2), + ), + ), + ).inOrder() + } + + @Test + fun `GIVEN single wallet with several accounts WHEN same currency in two accounts THEN it is Grouped`() = + runTest { + // Arrange + val chiaStatusMain = statusOf(chia, fiatAmount = BigDecimal(15)) + val chiaStatusSecondary = statusOf(chia, fiatAmount = BigDecimal(5)) + val cardanoStatus = statusOf(cardano, fiatAmount = BigDecimal(30)) + val mainAccount = mainAccount(walletId1) + val secondaryAccount = secondaryAccount(walletId1, derivationIndex = 1) + val wallet = wallet(walletId1) + + stub( + wallets = listOf(wallet), + statusLists = listOf( + statusList( + walletId = walletId1, + cryptoPortfolio(mainAccount, listOf(chiaStatusMain, cardanoStatus)), + cryptoPortfolio(secondaryAccount, listOf(chiaStatusSecondary)), + ), + ), + ) + + // Act — single wallet, but more than one account enables aggregation + val actual = search(query = "c") + + // Assert + assertThat(actual).containsExactly( + UserAssetSearchItem.Single(entry(wallet, mainAccount, cardanoStatus)), + UserAssetSearchItem.Grouped( + tokenName = chia.name, + tokenSymbol = chia.symbol, + tokenIconUrl = chia.iconUrl, + entries = listOf( + entry(wallet, mainAccount, chiaStatusMain), + entry(wallet, secondaryAccount, chiaStatusSecondary), + ), + ), + ).inOrder() + } + + @Test + fun `GIVEN custom token without backend id in two wallets WHEN search THEN grouped by name and symbol`() = + runTest { + // Arrange — a custom token has no rawCurrencyId, so the merge falls back to name + symbol + val customToken = customToken(name = "MyToken", symbol = "MTK") + val statusW1 = statusOf(customToken, fiatAmount = BigDecimal(7)) + val statusW2 = statusOf(customToken, fiatAmount = BigDecimal(3)) + val mainAccount1 = mainAccount(walletId1) + val mainAccount2 = mainAccount(walletId2) + val firstWallet = wallet(walletId1) + val secondWallet = wallet(walletId2) + + stub( + wallets = listOf(firstWallet, secondWallet), + statusLists = listOf( + statusList(walletId1, cryptoPortfolio(mainAccount1, listOf(statusW1))), + statusList(walletId2, cryptoPortfolio(mainAccount2, listOf(statusW2))), + ), + ) + + // Act + val actual = search(query = "myto") + + // Assert + assertThat(actual).containsExactly( + UserAssetSearchItem.Grouped( + tokenName = "MyToken", + tokenSymbol = "MTK", + tokenIconUrl = customToken.iconUrl, + entries = listOf( + entry(firstWallet, mainAccount1, statusW1), + entry(secondWallet, mainAccount2, statusW2), + ), + ), + ) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class WalletFiltering { + + @Test + fun `GIVEN locked wallet WHEN search THEN its assets are excluded`() = runTest { + // Arrange + val cardanoStatus = statusOf(cardano, fiatAmount = BigDecimal(10)) + val chiaStatus = statusOf(chia, fiatAmount = BigDecimal(20)) + val mainAccount1 = mainAccount(walletId1) + val mainAccount2 = mainAccount(walletId2) + val unlockedWallet = wallet(walletId1, locked = false) + val lockedWallet = wallet(walletId2, locked = true) + + stub( + wallets = listOf(unlockedWallet, lockedWallet), + statusLists = listOf( + statusList(walletId1, cryptoPortfolio(mainAccount1, listOf(cardanoStatus))), + statusList(walletId2, cryptoPortfolio(mainAccount2, listOf(chiaStatus))), + ), + ) + + // Act + val actual = search(query = "c") + + // Assert — only the single unlocked wallet remains, so no grouping and chia is gone + assertThat(actual).containsExactly( + UserAssetSearchItem.Single(entry(unlockedWallet, mainAccount1, cardanoStatus)), + ) + } + + @Test + fun `GIVEN all wallets locked WHEN search THEN user assets are empty`() = runTest { + // Arrange + val cardanoStatus = statusOf(cardano, fiatAmount = BigDecimal(10)) + stub( + wallets = listOf(wallet(walletId1, locked = true)), + statusLists = listOf( + statusList(walletId1, cryptoPortfolio(mainAccount(walletId1), listOf(cardanoStatus))), + ), + ) + + // Act + val actual = search(query = "c") + + // Assert + assertThat(actual).isEmpty() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Matching { + + @Test + fun `GIVEN upper case query matching symbol WHEN search THEN match is case insensitive`() = runTest { + // Arrange + val ethereumStatus = statusOf(ethereum, fiatAmount = BigDecimal(10)) + val mainAccount = mainAccount(walletId1) + val wallet = wallet(walletId1) + stub( + wallets = listOf(wallet), + statusLists = listOf(statusList(walletId1, cryptoPortfolio(mainAccount, listOf(ethereumStatus)))), + ) + + // Act + val actual = search(query = "ETH") + + // Assert + assertThat(actual).containsExactly( + UserAssetSearchItem.Single(entry(wallet, mainAccount, ethereumStatus)), + ) + } + + @Test + fun `GIVEN query matching nothing WHEN search THEN user assets are empty`() = runTest { + // Arrange + val cardanoStatus = statusOf(cardano, fiatAmount = BigDecimal(10)) + val mainAccount = mainAccount(walletId1) + stub( + wallets = listOf(wallet(walletId1)), + statusLists = listOf(statusList(walletId1, cryptoPortfolio(mainAccount, listOf(cardanoStatus)))), + ) + + // Act + val actual = search(query = "zzz") + + // Assert + assertThat(actual).isEmpty() + } + } + + // region helpers + + private fun stub(wallets: List, statusLists: List) { + every { multiAccountStatusListSupplier() } returns flowOf(statusLists) + every { userWalletsListRepository.userWallets } returns MutableStateFlow(wallets) + } + + private fun TestScope.search(query: String): List = + getEmittedValues(useCase(query)).last().userAssets + + private fun wallet(id: UserWalletId, locked: Boolean = false): UserWallet = mockk { + every { walletId } returns id + every { name } returns "Wallet ${id.stringValue}" + every { isLocked } returns locked + } + + private fun mainAccount(walletId: UserWalletId): Account.CryptoPortfolio = + Account.CryptoPortfolio.createMainAccount(userWalletId = walletId) + + private fun secondaryAccount(walletId: UserWalletId, derivationIndex: Int): Account.CryptoPortfolio { + val index = DerivationIndex(derivationIndex).getOrNull()!! + return Account.CryptoPortfolio( + accountId = AccountId.forCryptoPortfolio(userWalletId = walletId, derivationIndex = index), + accountName = AccountName("Account #$derivationIndex").getOrNull()!!, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), + derivationIndex = index, + cryptoCurrencies = emptyList(), + ) + } + + private fun statusOf(currency: CryptoCurrency, fiatAmount: BigDecimal?): CryptoCurrencyStatus { + val statusValue = mockk { + every { this@mockk.fiatAmount } returns fiatAmount + } + return CryptoCurrencyStatus(currency = currency, value = statusValue) + } + + private fun cryptoPortfolio( + account: Account.CryptoPortfolio, + statuses: List, + ): AccountStatus.CryptoPortfolio = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = statuses, + ), + priceChangeLce = lceLoading(), + ) + + private fun statusList(walletId: UserWalletId, vararg accounts: AccountStatus.CryptoPortfolio): AccountStatusList = + AccountStatusList( + userWalletId = walletId, + accountStatuses = accounts.toList(), + totalAccounts = accounts.size, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + + private fun entry( + wallet: UserWallet, + account: Account.CryptoPortfolio, + status: CryptoCurrencyStatus, + ): UserAssetEntry = UserAssetEntry( + userWalletId = wallet.walletId, + userWalletName = wallet.name, + accountId = account.accountId, + accountName = account.accountName, + accountIcon = account.icon, + currencyStatus = status, + ) + + private fun customToken(name: String, symbol: String): CryptoCurrency.Token { + val network = ethereum.network + val contractAddress = "0xCUSTOM_$symbol" + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawId = "custom-network"), + suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = contractAddress), + ), + network = network, + name = name, + symbol = symbol, + decimals = 8, + iconUrl = null, + isCustom = true, + contractAddress = contractAddress, + ) + } + + // endregion +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt index 0dbf99fde6..179376d9d2 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt @@ -126,7 +126,8 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { LazyColumn( modifier = Modifier .fillMaxSize() - .nestedScroll(nestedScrollConnection), + .nestedScroll(nestedScrollConnection) + .testTag(BuyTokenScreenTestTags.LAZY_LIST), state = lazyListState, contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/SortByMenu.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/SortByMenu.kt index 4e8635b86b..b34f053ffa 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/SortByMenu.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/SortByMenu.kt @@ -10,7 +10,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp @@ -40,6 +39,7 @@ internal fun SortByMenu( Column { Row( horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .widthIn(238.dp) @@ -50,7 +50,7 @@ internal fun SortByMenu( }, ) .padding( - vertical = TangemTheme.dimens2.x5, + vertical = TangemTheme.dimens2.x2_5, horizontal = TangemTheme.dimens2.x4, ), ) { @@ -60,27 +60,30 @@ internal fun SortByMenu( color = TangemTheme.colors2.text.neutral.primary, maxLines = 1, ) - if (sortMenuUM.selectedOption == sortType) { - Box( - modifier = Modifier - .padding(TangemTheme.dimens2.x0_5) - .size(TangemTheme.dimens2.x5) - .background( - color = TangemTheme.colors2.graphic.neutral.primary, - shape = CircleShape, - ), - ) { - Icon( - painter = rememberVectorPainter( - ImageVector.vectorResource(R.drawable.ic_check_default_24), - ), - contentDescription = null, - tint = TangemTheme.colors2.graphic.neutral.primaryInverted, + Box( + modifier = Modifier.size(TangemTheme.dimens2.x6), + contentAlignment = Alignment.Center, + ) { + if (sortMenuUM.selectedOption == sortType) { + Box( modifier = Modifier - .align(Alignment.Center) .padding(TangemTheme.dimens2.x0_5) - .size(TangemTheme.dimens2.x4), - ) + .size(TangemTheme.dimens2.x5) + .background( + color = TangemTheme.colors2.graphic.neutral.primary, + shape = CircleShape, + ), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_check_default_24), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primaryInverted, + modifier = Modifier + .align(Alignment.Center) + .padding(TangemTheme.dimens2.x0_5) + .size(TangemTheme.dimens2.x4), + ) + } } } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index f4e2a0a1f5..0a0f2dc41d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -45,6 +45,7 @@ import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefres import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.components.text.applyBladeBrush import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme @@ -539,7 +540,16 @@ internal class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider item("cardsBlock") { + SpacerH12() CardsBlock(cardsBlockState = cardsState) } } if (state.balanceBlockState.actionButtons.isNotEmpty()) { item("actionButtonsBlock") { + SpacerH24() ActionBlock(actionButtons = state.balanceBlockState.actionButtons) } } if (state.errorNotificationConfig != null) { item("errorSessionBannerBlock") { + SpacerH12() ErrorMessage( config = state.errorNotificationConfig, modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), @@ -175,6 +175,7 @@ private fun LazyListScope.payDetailsBody(state: TangemPayDetailsUM) { when (progressBanner) { CardsProgressBannerUM.Reissuing -> { item("reissuingBannerBlock") { + SpacerH12() TangemMessage( modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), title = resourceReference(R.string.tangempay_reissue_card_in_progress), @@ -184,6 +185,7 @@ private fun LazyListScope.payDetailsBody(state: TangemPayDetailsUM) { } CardsProgressBannerUM.Issuing -> { item("issuingBannerBlock") { + SpacerH12() TangemMessage( modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), title = resourceReference(R.string.tangempay_issuing_new_digital_card_title), @@ -203,6 +205,7 @@ private fun LazyListScope.payDetailsBody(state: TangemPayDetailsUM) { null -> { if (state.addToWalletBlockState != null) { item("addToWalletBannerBlock") { + SpacerH12() TangemPayAddToWalletBlock( state = state.addToWalletBlockState, modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), @@ -211,7 +214,11 @@ private fun LazyListScope.payDetailsBody(state: TangemPayDetailsUM) { } if (state.accountDeactivatedNotificationConfig != null) { item("deactivationBannerBlock") { - ErrorMessage(state.accountDeactivatedNotificationConfig) + SpacerH12() + ErrorMessage( + config = state.accountDeactivatedNotificationConfig, + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), + ) } } } @@ -384,7 +391,7 @@ private fun CardsBlock( state = rememberLazyListState(), contentPadding = PaddingValues( horizontal = TangemTheme.dimens2.x4, - vertical = TangemTheme.dimens2.x6, + vertical = TangemTheme.dimens2.x3, ), horizontalArrangement = Arrangement.Center, ) { @@ -415,7 +422,7 @@ private fun LazyItemScope.ActionBlock( Row( modifier = modifier .fillMaxWidth() - .padding(vertical = TangemTheme.dimens2.x6), + .padding(vertical = TangemTheme.dimens2.x3), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeDropDownMenu.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeDropDownMenu.kt index f041d28b88..25b11c610d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeDropDownMenu.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeDropDownMenu.kt @@ -10,7 +10,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp @@ -80,7 +79,7 @@ private fun SortByBalanceMenuSection(organizeMenuUM: OrganizeTokensUM.OrganizeMe }, enabled = !organizeMenuUM.isSortedByBalance, ) - .padding(vertical = TangemTheme.dimens2.x5, horizontal = TangemTheme.dimens2.x4), + .padding(vertical = TangemTheme.dimens2.x2_5, horizontal = TangemTheme.dimens2.x4), ) HorizontalDivider( @@ -93,6 +92,7 @@ private fun SortByBalanceMenuSection(organizeMenuUM: OrganizeTokensUM.OrganizeMe private fun GroupTokensMenuSection(organizeMenuUM: OrganizeTokensUM.OrganizeMenuUM, onDropdownDismiss: () -> Unit) { Row( horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .widthIn(238.dp) @@ -102,7 +102,7 @@ private fun GroupTokensMenuSection(organizeMenuUM: OrganizeTokensUM.OrganizeMenu onDropdownDismiss() }, ) - .padding(vertical = TangemTheme.dimens2.x5, horizontal = TangemTheme.dimens2.x4), + .padding(vertical = TangemTheme.dimens2.x2_5, horizontal = TangemTheme.dimens2.x4), ) { Text( text = stringResourceSafe(R.string.organize_tokens_group), @@ -110,27 +110,30 @@ private fun GroupTokensMenuSection(organizeMenuUM: OrganizeTokensUM.OrganizeMenu color = TangemTheme.colors2.text.neutral.primary, maxLines = 1, ) - if (organizeMenuUM.isGrouped) { - Box( - modifier = Modifier - .padding(TangemTheme.dimens2.x0_5) - .size(TangemTheme.dimens2.x5) - .background( - color = TangemTheme.colors2.graphic.neutral.primary, - shape = CircleShape, - ), - ) { - Icon( - painter = rememberVectorPainter( - ImageVector.vectorResource(R.drawable.ic_check_default_24), - ), - contentDescription = null, - tint = TangemTheme.colors2.graphic.neutral.primaryInverted, + Box( + modifier = Modifier.size(TangemTheme.dimens2.x6), + contentAlignment = Alignment.Center, + ) { + if (organizeMenuUM.isGrouped) { + Box( modifier = Modifier - .align(Alignment.Center) .padding(TangemTheme.dimens2.x0_5) - .size(TangemTheme.dimens2.x4), - ) + .size(TangemTheme.dimens2.x5) + .background( + color = TangemTheme.colors2.graphic.neutral.primary, + shape = CircleShape, + ), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_check_default_24), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primaryInverted, + modifier = Modifier + .align(Alignment.Center) + .padding(TangemTheme.dimens2.x0_5) + .size(TangemTheme.dimens2.x4), + ) + } } } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/tokenActions/TokenActionContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/tokenActions/TokenActionContent.kt index 449d0a9eb0..85efce67c6 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/tokenActions/TokenActionContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/tokenActions/TokenActionContent.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector @@ -93,17 +94,17 @@ private fun TokenActionContextMenuContent(actions: ImmutableList Column { Row( - horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2_5), + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .testTag(BaseBottomSheetTestTags.ACTION_BUTTON) + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens2.x2_5)) .clickable( enabled = item.isEnabled, onClick = { @@ -112,10 +113,8 @@ private fun TokenActionContextMenuContent(actions: ImmutableList BoostBlockState.Hidden now >= qualificationEndDate + AWAITING_PAYOUT_WINDOW -> BoostBlockState.Hidden now >= qualificationEndDate -> BoostBlockState.AwaitingPayout - else -> BoostBlockState.DaysLeft(days = (qualificationEndDate - now).inWholeDays.toInt()) + else -> { + val remaining = qualificationEndDate - now + val fullDays = remaining.inWholeDays + val daysLeft = if (remaining > fullDays.days) fullDays + 1 else fullDays + BoostBlockState.DaysLeft(days = daysLeft.toInt()) + } } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt index ebb0ceb070..8751c8920d 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt @@ -11,7 +11,6 @@ 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.navigation.url.UrlOpener -import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.combinedReference import com.tangem.core.ui.extensions.pluralReference @@ -74,7 +73,6 @@ internal class YieldSupplyActiveModel @Inject constructor( private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase, private val getYieldBoostStatusUseCase: GetYieldBoostStatusUseCase, private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, - private val designFeatureToggles: DesignFeatureToggles, private val boostStoryPreloader: YieldBoostStoryPreloader, ) : Model(), YieldSupplyStopEarningComponent.ModelCallback, YieldSupplyApproveComponent.ModelCallback { @@ -239,7 +237,6 @@ internal class YieldSupplyActiveModel @Inject constructor( private fun loadBoostBlock() { if (!yieldSupplyFeatureToggles.isYieldPromoEnabled) return - if (designFeatureToggles.isRedesignEnabled) return modelScope.launch(dispatchers.io) { val token = cryptoCurrency as? CryptoCurrency.Token ?: return@launch val cached = getYieldBoostStatusUseCase(userWalletId).getOrNull() diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt index f76c3881c8..3be94f3cc6 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt @@ -6,7 +6,6 @@ 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.navigation.Router -import com.tangem.core.ui.DesignFeatureToggles import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrency @@ -33,7 +32,6 @@ internal class YieldSupplyEntryModel @Inject constructor( private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val isYieldBoostPromoEnabledForTokenUseCase: IsYieldBoostPromoEnabledForTokenUseCase, private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, - private val designFeatureToggles: DesignFeatureToggles, ) : Model() { private val params = paramsContainer.require() @@ -99,7 +97,6 @@ internal class YieldSupplyEntryModel @Inject constructor( YieldSupplyEntryRoute.Active(cryptoCurrency = token) } else { val isPromoEnabled = yieldSupplyFeatureToggles.isYieldPromoEnabled && - !designFeatureToggles.isRedesignEnabled && isYieldBoostPromoEnabledForTokenUseCase(userWalletId, token).getOrElse { false } YieldSupplyEntryRoute.Promo( cryptoCurrency = token, diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockStateTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockStateTest.kt index 63580acb2c..95cf198764 100644 --- a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockStateTest.kt +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockStateTest.kt @@ -26,13 +26,34 @@ internal class BoostBlockStateTest { } @Test - fun `GIVEN qualificationEndDate less than a day away WHEN resolve THEN DaysLeft zero`() { + fun `GIVEN qualificationEndDate less than a day away WHEN resolve THEN DaysLeft one`() { val result = resolveBoostBlockState( qualificationEndDate = Instant.parse("2026-05-28T18:00:00Z"), now = now, ) - assertThat(result).isEqualTo(BoostBlockState.DaysLeft(days = 0)) + assertThat(result).isEqualTo(BoostBlockState.DaysLeft(days = 1)) + } + + @Test + fun `GIVEN qualificationEndDate just over a day away WHEN resolve THEN DaysLeft rounds up`() { + val result = resolveBoostBlockState( + // 1d 1h away — rounds up to 2 + qualificationEndDate = Instant.parse("2026-05-29T01:00:00Z"), + now = now, + ) + + assertThat(result).isEqualTo(BoostBlockState.DaysLeft(days = 2)) + } + + @Test + fun `GIVEN qualificationEndDate exactly whole days away WHEN resolve THEN DaysLeft exact`() { + val result = resolveBoostBlockState( + qualificationEndDate = Instant.parse("2026-05-30T00:00:00Z"), + now = now, + ) + + assertThat(result).isEqualTo(BoostBlockState.DaysLeft(days = 2)) } @Test diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModelBoostBlockTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModelBoostBlockTest.kt index 9f1f466d7d..311aa39285 100644 --- a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModelBoostBlockTest.kt +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModelBoostBlockTest.kt @@ -5,7 +5,6 @@ import com.google.common.truth.Truth.assertThat import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.DesignFeatureToggles import com.tangem.common.routing.AppRouter import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -56,7 +55,6 @@ class YieldSupplyActiveModelBoostBlockTest { private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase = mockk(relaxed = true) private val getYieldBoostStatusUseCase: GetYieldBoostStatusUseCase = mockk(relaxed = true) private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles = mockk(relaxed = true) - private val designFeatureToggles: DesignFeatureToggles = mockk(relaxed = true) private val boostStoryPreloader: YieldBoostStoryPreloader = mockk(relaxed = true) private val analyticsHandler: AnalyticsEventHandler = mockk(relaxed = true) @@ -86,7 +84,6 @@ class YieldSupplyActiveModelBoostBlockTest { @BeforeEach fun setUp() { every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns true - every { designFeatureToggles.isRedesignEnabled } returns false every { getUserWalletUseCase.invoke(userWalletId) } returns userWallet.right() every { singleAccountStatusListSupplier.invoke(userWalletId) } returns emptyFlow() coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right() @@ -112,7 +109,6 @@ class YieldSupplyActiveModelBoostBlockTest { yieldSupplyGetDustMinAmountUseCase = yieldSupplyGetDustMinAmountUseCase, getYieldBoostStatusUseCase = getYieldBoostStatusUseCase, yieldSupplyFeatureToggles = yieldSupplyFeatureToggles, - designFeatureToggles = designFeatureToggles, boostStoryPreloader = boostStoryPreloader, )