Updated on 2026-08-14
This commit is contained in:
commit
bac330628f
29 changed files with 756 additions and 150 deletions
|
|
@ -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.
|
||||
|
|
@ -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<LazyListItemNode> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LazyListItemNode> {
|
||||
hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM)
|
||||
hasText(tokenTitle)
|
||||
useUnmergedTree = true
|
||||
}
|
||||
}
|
||||
|
||||
internal fun BaseTestCase.onAddFundsBottomSheet(function: AddFundsBottomSheetPageObject.() -> Unit) =
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@
|
|||
},
|
||||
{
|
||||
"name": "AND_15482_SURVEYSPARROW_ENABLED",
|
||||
"version": "6.0"
|
||||
"version": "undefined"
|
||||
},
|
||||
{
|
||||
"name": "AND_15258_QUICK_TOP_UP_ENABLED",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<SearchRepository>()
|
||||
private val multiAccountStatusListSupplier = mockk<MultiAccountStatusListSupplier>()
|
||||
private val userWalletsListRepository = mockk<UserWalletsListRepository>()
|
||||
|
||||
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<RecentSearchToken>()
|
||||
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<UserWallet>, statusLists: List<AccountStatusList>) {
|
||||
every { multiAccountStatusListSupplier() } returns flowOf(statusLists)
|
||||
every { userWalletsListRepository.userWallets } returns MutableStateFlow(wallets)
|
||||
}
|
||||
|
||||
private fun TestScope.search(query: String): List<UserAssetSearchItem> =
|
||||
getEmittedValues(useCase(query)).last().userAssets
|
||||
|
||||
private fun wallet(id: UserWalletId, locked: Boolean = false): UserWallet = mockk<UserWallet.Cold> {
|
||||
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<CryptoCurrencyStatus.Value> {
|
||||
every { this@mockk.fiatAmount } returns fiatAmount
|
||||
}
|
||||
return CryptoCurrencyStatus(currency = currency, value = statusValue)
|
||||
}
|
||||
|
||||
private fun cryptoPortfolio(
|
||||
account: Account.CryptoPortfolio,
|
||||
statuses: List<CryptoCurrencyStatus>,
|
||||
): 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
|
||||
}
|
||||
|
|
@ -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(),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T
|
|||
isBalanceHidden = false,
|
||||
addToWalletBlockState = null,
|
||||
accountDeactivatedNotificationConfig = null,
|
||||
errorNotificationConfig = null,
|
||||
errorNotificationConfig = NotificationConfig(
|
||||
title = TextReference.Str("Error title"),
|
||||
subtitle = TextReference.Str("Error subtitle"),
|
||||
iconResId = R.drawable.ic_alert_circle_24,
|
||||
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
|
||||
text = TextReference.Str("Error btn text"),
|
||||
onClick = {},
|
||||
iconResId = R.drawable.ic_tangem_24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.ui.components.SpacerH12
|
||||
import com.tangem.core.ui.components.SpacerH24
|
||||
import com.tangem.core.ui.components.SpacerW
|
||||
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
|
||||
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshSlidingContainer
|
||||
|
|
@ -55,12 +57,7 @@ import com.tangem.features.tangempay.components.express.PreviewEmptyExpressTrans
|
|||
import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent
|
||||
import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent
|
||||
import com.tangem.features.tangempay.details.impl.R
|
||||
import com.tangem.features.tangempay.entity.CardsProgressBannerUM
|
||||
import com.tangem.features.tangempay.entity.TangemPayCardUiState
|
||||
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
|
||||
import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig
|
||||
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
|
||||
import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM
|
||||
import com.tangem.features.tangempay.entity.*
|
||||
import com.tangem.features.tangempay.ui.components.PayContextMenuBlock
|
||||
import com.tangem.features.tangempay.ui.components.TangemPayActionButton
|
||||
import com.tangem.features.tangempay.ui.components.TangemPayAddCardView
|
||||
|
|
@ -152,17 +149,20 @@ private fun LazyListScope.payDetailsBody(state: TangemPayDetailsUM) {
|
|||
}
|
||||
state.balanceBlockState.cardsBlockState?.let { cardsState ->
|
||||
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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TokenActionButt
|
|||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(min = 206.dp)
|
||||
.padding(
|
||||
vertical = TangemTheme.dimens2.x2_5,
|
||||
horizontal = TangemTheme.dimens2.x4,
|
||||
),
|
||||
.padding(TangemTheme.dimens2.x1_5),
|
||||
) {
|
||||
actions.fastForEach { item ->
|
||||
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<TokenActionButt
|
|||
},
|
||||
)
|
||||
.padding(
|
||||
start = TangemTheme.dimens2.x1_5,
|
||||
end = TangemTheme.dimens2.x2,
|
||||
top = TangemTheme.dimens2.x2_5,
|
||||
bottom = TangemTheme.dimens2.x2_5,
|
||||
horizontal = TangemTheme.dimens2.x2_5,
|
||||
vertical = TangemTheme.dimens2.x2_5,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
|
|
@ -143,10 +142,7 @@ private fun TokenActionContextMenuContent(actions: ImmutableList<TokenActionButt
|
|||
if (item.hasDivider) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
vertical = TangemTheme.dimens2.x2_5,
|
||||
horizontal = TangemTheme.dimens2.x2,
|
||||
)
|
||||
.padding(vertical = TangemTheme.dimens2.x1)
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(TangemTheme.colors2.border.neutral.primary),
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ internal class GetWalletNotificationsFactory @Inject constructor(
|
|||
|
||||
val cardTypesResolver = userWallet.scanResponse.cardTypesResolver
|
||||
addIf(
|
||||
element = WalletNotificationUM.BackupError { clickIntents.onSupportClick() },
|
||||
element = WalletNotificationUM.BackupError { clickIntents.onBackupErrorClick() },
|
||||
condition = isWalletBackupProblematicUseCase(userWallet),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -23,5 +23,10 @@ internal fun resolveBoostBlockState(qualificationEndDate: Instant?, now: Instant
|
|||
qualificationEndDate == null -> 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<YieldSupplyEntryComponent.Params>()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue