Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-10 20:23:03 +02:00
commit 49515ccbd7
52 changed files with 2394 additions and 167 deletions

View file

@ -0,0 +1,163 @@
# Design System
The app currently hosts **three generations of the design system (DS)** side by side. They differ by
folder, token set (colors / typography / dimensions), and the `@Preview` wrapper. Knowing which
generation a component belongs to is essential so you don't mix tokens or pull the wrong building blocks.
## Three generations
| Generation | Folder | Colors | Typography | Dimensions | Preview wrapper |
|---|---|---|---|---|---|
| **DS1** (legacy) | `core/ui/src/main/java/com/tangem/core/ui/components/` | `TangemTheme.colors` | `TangemTheme.typography` | `TangemTheme.dimens` | `TangemThemePreview` |
| **DS2** (redesign) | `core/ui/src/main/java/com/tangem/core/ui/ds/` | `TangemTheme.colors2` | `TangemTheme.typography2` | `TangemTheme.dimens2` | `TangemThemePreviewRedesign` |
| **DS3** (target) | `core/ui/src/main/java/com/tangem/core/ui/ds2/` | `TangemTheme.colors3` | `TangemTheme.typography3` | `TangemTheme.dimens2` | `TangemThemePreviewRedesign` |
> Mind the numbering mismatch: **folder `ds` is DS2**, **folder `ds2` is DS3**.
> The `colors2` / `typography2` tokens are `@Deprecated` (ReplaceWith `colors3` / `typography3`).
- **DS1** — the entire current app is built on it. Do **not** add new components here.
- **DS2** — redesign components. A transitional generation; don't write new components in it, only
maintain what already exists.
- **DS3** — the newest design system; **the whole app is being migrated to it**. Build new DS
components here.
## Using DS3 in features
**All DS3 components (folder `ds2`) may be used in features starting from app version 6.0.** Before
6.0 they must not be used on product screens.
If a needed component does not yet exist in DS3, **add it by analogy with the existing ones** (see the
pattern below).
## DS3 component pattern
Study the existing components as references:
- Simple: `ds2/checkbox/TangemCheckmark.kt` — single file, a public `@Composable` function + `@Preview`.
- Composite: `ds2/button/``TangemButton.kt` (public API), `TangemButtonInternal.kt` (private inner
layout), `TangemButtonExt.kt` (variant / size tokens).
Pattern rules:
1. **Package & location.** `com.tangem.core.ui.ds2.<component>`, folder
`core/ui/.../ds2/<component>/`. The component name is `Tangem<Name>`.
2. **DS3 tokens only.** Colors — `TangemTheme.colors3.*`, text — `TangemTheme.typography3.*`,
dimensions — `TangemTheme.dimens2.*`. No `colors` / `colors2` / hardcoded values (literal dp/colors
are acceptable only inside `@Preview`, where you add `@Suppress("MagicNumber")`).
3. **Signature.** `modifier: Modifier = Modifier` is mandatory (defaulting to `Modifier`, placed first
among the optional params or right after the required ones). Express variants/sizes via a nested
`enum` in `object Tangem<Name>` (like `TangemButton.Variant` / `TangemButton.Size`), not boolean flags.
4. **Accessibility.** Pass `contentDescription`, set the `Role`, mark `disabled()` in `semantics`, and
handle focus/press state via `interactionSource`.
5. **KDoc + Figma link.** Above the public function — KDoc describing behavior, every parameter, and a
link to the Figma node (see the KDoc requirements below).
6. **Previews.** Two `@Preview`s (Light + Dark via `UI_MODE_NIGHT_YES`), wrapped in
`TangemThemePreviewRedesign { ... }`, with `TangemTheme.colors3.bg.primary` as the background.
Preview helpers (`PreviewRow`, `Section`, etc.) are private in the same file.
7. **Composite components** (many variants / heavy layout) are split into 3 files like the button:
public `Tangem<Name>.kt`, private `Tangem<Name>Internal.kt`, tokens `Tangem<Name>Ext.kt`.
## API conventions
### Public properties live in the `object`
Any public type the component exposes — variant/size/role/align enums, status classes, constants —
is declared inside the namesake `object Tangem<Name>`, **not** as a top-level type. This keeps a single
`Tangem<Name>.Variant` / `Tangem<Name>.Size` / `Tangem<Name>.Role` namespace at the call site and
avoids polluting the package.
```kotlin
object TangemTopNavigation {
/** Horizontal alignment of the center content slot. */
enum class ContentAlign { Start, Center }
}
// usage: TangemTopNavigation.ContentAlign.Center
```
References: `TangemTopNavigation.ContentAlign`, `TangemNavigationText.Role`, `TangemButton.Variant` /
`TangemButton.Size`.
### Provide convenient overloads
A component should ship ergonomic overloads so callers don't assemble boilerplate for the common case.
Two acceptable shapes:
1. **Additional `@Composable fun` overloads** with simpler parameters that delegate to the base one.
`TangemTopNavigation` has a low-level slot-based overload (`startButton`/`endButton`/`contentColumn`
lambdas) plus several high-level overloads taking `title` / `subtitle` / `onBack` / `onClose` that
wire the predefined buttons and the title/subtitle center for you.
2. **Extension functions on the `object`** for named presets — e.g. `@Composable fun TangemButton.Back(…)`
and `TangemButton.Close(…)` in `TangemButtonExt.kt` expose ready-made button presets while reading
as `TangemButton.Back { … }` at the call site.
Each overload keeps the same rules as the base component (`modifier` first among optionals, DS3 tokens,
its own KDoc — see below).
### Sub-components are first-class
Internal building blocks that are themselves public (e.g. `TangemNavigationText`, used for the
`TangemTopNavigation` title/subtitle slots) follow the **exact same rules** as a top-level component:
DS3 tokens only, `modifier: Modifier = Modifier`, public properties in their own `object`
(`TangemNavigationText.Role`), full KDoc, and their own Storybook entry where it makes sense. Don't
treat "helper" composables as second-class — if a feature can call it, it is a documented DS component.
## KDoc requirements for components
Every public DS component (and any non-trivial public composable) must carry a KDoc block. Use
`ds2/button/TangemButton.kt` and `ds2/checkbox/TangemCheckmark.kt` as the canonical examples.
A component KDoc must contain, in order:
1. **Summary line.** One sentence stating what the component is and which generation it belongs to —
start with `Design-system v2 …` for DS3 components (matches the existing wording).
2. **Figma link.** A markdown link to the exact Figma node:
`[Figma](https://www.figma.com/design/…?node-id=…)`. A component without a Figma reference is not
review-ready.
3. **Behavior notes** (when behavior is non-obvious). A short prose paragraph or a bulleted
`Behavior notes:` list covering state-dependent rendering — loading, disabled/enabled, icon-only
vs. labeled, focus ring, animations, what overrides what. Describe *observable behavior*, not the
implementation.
4. **`@param` for every parameter.** No parameter may be left undocumented — including `modifier`
when its effect is non-trivial (e.g. "Pass `Modifier.fillMaxWidth()` to switch to fixed-width
layout"). Each `@param` states the meaning **and** the consequences of notable values
(`null` → non-interactive, `false` → dimmed & clicks ignored, etc.).
5. **Accessibility guidance** where relevant — e.g. when `contentDescription` should be supplied
(icon-only buttons, loading state, disabled state) and what it announces.
Additional rules:
- Document the **nested `enum`s** (`Variant`, `Size`, `Status`, …) too: a short KDoc on the enum and,
where the options aren't self-explanatory, a one-line description per entry (see `TangemButton.Variant`).
- Keep KDoc about **contract and behavior**, not internals. Implementation comments explaining *why*
a specific approach was taken belong to inline `//` comments inside the body, not the KDoc.
- Reference other DS types with `[TangemSurface]` / `[TangemButton.Variant]` link syntax so they
resolve in the IDE.
- Detekt enforces missing-KDoc-on-public-API style checks on `core:ui`; run `./gradlew :core:ui:detektMain`.
## Storybook
Add every DS3 component to the **Storybook** (module `features/tester`) — a live on-device/emulator
component gallery (Tester → Storybook → DS Components).
Use the **`add-storybook-component`** skill — it wires the entity, the Build factory, the Composable
page, and registers it in the correct list. Run: `/add-storybook-component TangemCheckmark (DS)`.
Page layout guidelines live in
`features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/STORYBOOK.md`.
## Checklist: adding a new DS3 component
- [ ] Component created under `core/ui/.../ds2/<component>/`, package `com.tangem.core.ui.ds2.<component>`.
- [ ] Named `Tangem<Name>`; first optional parameter is `modifier: Modifier = Modifier`.
- [ ] Uses **only** DS3 tokens: `colors3`, `typography3`, `dimens2`. No hardcoded values outside previews.
- [ ] Variants/sizes expressed as an `enum` inside `object Tangem<Name>` (not a set of boolean flags).
- [ ] All public types (enums, statuses, constants) declared inside the `object Tangem<Name>`.
- [ ] Convenient overloads provided (simpler `@Composable` overloads and/or `object` extension presets).
- [ ] Public sub-components (e.g. `TangemNavigationText`) follow the same rules + KDoc as a full component.
- [ ] States handled: enabled/disabled, press/focus (`interactionSource`), loading (if applicable).
- [ ] Accessibility: `contentDescription`, `Role`, `disabled()` in `semantics`.
- [ ] KDoc per the requirements above (summary + Figma link + behavior notes + every `@param` + a11y).
- [ ] Two `@Preview`s (Light/Dark) in `TangemThemePreviewRedesign`, background `colors3.bg.primary`.
- [ ] Heavy component split into `Tangem<Name>.kt` / `…Internal.kt` / `…Ext.kt`.
- [ ] Storybook page added (`add-storybook-component` skill).
- [ ] Detekt passes: `./gradlew :core:ui:detektMain` (plus
`./gradlew :features:tester:impl:assembleGoogleDebug` if you touched the Storybook).
- [ ] Use in product features only from app version **6.0** onward.

View file

@ -0,0 +1,150 @@
---
name: add-storybook-component
description: Add a component showcase page to the Tangem storybook (in features/tester). Wires the entity, Build factory, Composable page, and registers it either in the "DS Components" sub-list (first/default target — for design-system components under core.ui.ds2.*) or in the root storybook list (second target — for any other component). Use when asked to "add a storybook page/story", "add <Component> to the storybook", "сделай сторибук для <компонент>", "добавь стори/историю в storybook", or to showcase a DS component in the tester.
allowed-tools: Read, Grep, Glob, Bash, Edit, Write
argument-hint: [component to add, e.g. "TangemCheckbox (DS)" or "MyLegacyCard"]
---
Add a new component page to the Tangem storybook. The storybook lives in
`features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/`
and renders interactive DS/component showcases on a device or emulator.
This is an **interactive** skill: read the real production component first to get its actual
parameters, enums, and package — never guess the API. Then mirror the closest existing story.
## Two placement targets — pick one
| Target | Use for | List screen | Page dir | Entity supertype |
|---|---|---|---|---|
| **1. DS Components (default)** | Design-system components under `com.tangem.core.ui.ds2.*` (the newest "DS3"/redesign components: `TangemButton`, `TangemBadge`, `TangemRow`, `TangemLoader`, …) | `page/ds/DsComponentsListScreen.kt``buildDsStories()` | `page/ds/<component>/` | `DsStoryBookPage` |
| **2. Other components** | Anything else (legacy/cross-cutting components, backgrounds, effects, typography demos) | `ui/StoryBookListScreen.kt``buildStories()` | `page/<component>/` | `StoryBookPage` |
**Default to Target 1 (DS Components)** when the component lives under `core.ui.ds2.*` or the user
mentions "DS"/"ds3"/"design system". Only the **list screen** and **page directory** differ between
the two targets — everything else (entity declaration file, `StoryBookScreen.kt` routing, factory
pattern) is identical.
> The ONLY behavioral difference of `DsStoryBookPage` vs `StoryBookPage`: `StoryBookViewModel.onBackClick`
> routes a `DsStoryBookPage` back to the DS sub-list, while a plain `StoryBookPage` routes back to the
> root list. That's it.
## Reference
`features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/STORYBOOK.md` is the canonical doc — read it for the **design guidelines** (mandatory
page layout: single live preview pinned at top + one control per parameter below, chip-selector pattern,
colors, realistic text). This skill covers the *wiring*; STORYBOOK.md covers the *look*.
Best reference implementations to mirror:
- **Stateful DS page with many controls:** `page/ds/button/` (TangemButton — variant/size/background
selectors, toggles, text-scale slider, blur backdrop). Read all three files: `Build.kt`,
`TangemButtonStory.kt`, and the `TangemButtonStory` entity in `entity/StoryBookPage.kt`.
- **Simple stateful page:** `page/ds/loader/` (TangemLoader — single size selector).
- **Stateless page (no params):** a `data object` sibling such as `ButtonsStory`.
## Workflow
1. **Read the production component.** Grep `core/ui/src/main/java/com/tangem/core/ui/ds2/<name>/`
(or wherever it lives) for the composable signature, its `enum`s (Variant/Size/Status/…), and
required vs optional params. The set of parameters becomes the set of controls.
2. **Decide stateless vs stateful:**
- **Stateless** (`data object`) — ONLY if the component has no configurable parameters at all.
- **Stateful** (`data class`) — the normal case: one field per parameter the user can change, each
paired with an `onXxxChange`/`onXxxToggle` lambda.
3. **Pick the target** (see table above) and **mirror the closest sibling**.
4. **Do the 4 edits + 1 new dir** (Steps AE below).
5. **Verify it compiles** (see Build).
## The edits
Assume component `Foo` rendered by `com.tangem.core.ui.ds2.foo.TangemFoo` with a `Variant` enum and an
`isEnabled` flag. Adjust names to the real component. `<page-dir>` =
`page/ds/foo/` for Target 1, or `page/foo/` for Target 2.
### A. Declare the entity in `entity/StoryBookPage.kt`
Stateful (normal):
```kotlin
internal data class TangemFooStory(
val variant: TangemFoo.Variant,
val isEnabled: Boolean,
val onVariantChange: (TangemFoo.Variant) -> Unit,
val onEnabledToggle: () -> Unit,
) : DsStoryBookPage // <- StoryBookPage for Target 2
```
Stateless: `internal data object TangemFooStory : DsStoryBookPage` (or `StoryBookPage`).
Add the matching import for the production type at the top of the file.
### B. Create `<page-dir>/Build.kt`
Stateful — uses `storyPageFactory` + `StateUpdater`:
```kotlin
internal fun StateUpdater<TangemFooStory>.build(): TangemFooStory {
return TangemFooStory(
variant = TangemFoo.Variant.Primary,
isEnabled = true,
onVariantChange = { v -> updateStory { it.copy(variant = v) } },
onEnabledToggle = { updateStory { it.copy(isEnabled = !it.isEnabled) } },
)
}
internal val tangemFooStoryFactory
get() = storyPageFactory(StateUpdater<TangemFooStory>::build)
```
Stateless: `internal val tangemFooStoryFactory: StoryPageFactory = StoryPageFactory { TangemFooStory }`
### C. Create `<page-dir>/TangemFooStory.kt`
`@Composable internal fun TangemFooStory(state: TangemFooStory, modifier: Modifier = Modifier)`
(drop `state` for stateless). Follow STORYBOOK.md design guidelines: live preview pinned at the top
in a `Column`, controls scrolling below. Reuse the chip-selector / toggle-row patterns from
`page/ds/button/TangemButtonStory.kt` (its `Section`, `ChipGrid`, `Chip`, `ToggleRow` are private —
copy the ones you need into the new file). Use representative text, not "Btn".
### D. Register routing in `ui/StoryBookScreen.kt`
Add both imports (entity + page composable share the simple name — Kotlin resolves them by position):
```kotlin
import com.tangem.feature.tester.presentation.storybook.entity.TangemFooStory
import com.tangem.feature.tester.presentation.storybook.page.ds.foo.TangemFooStory
```
Add a branch to the `when (storyState)`:
```kotlin
is TangemFooStory -> TangemFooStory(state = storyState) // stateless: TangemFooStory -> TangemFooStory()
```
### E. Register in the list screen (target-specific)
- **Target 1 (DS):** in `page/ds/DsComponentsListScreen.kt` add the factory import and a row to
`buildDsStories()`:
```kotlin
DsStoryItem(title = "🔘 TangemFoo", factory = tangemFooStoryFactory),
```
- **Target 2 (other):** in `ui/StoryBookListScreen.kt` add the factory import and a row to
`buildStories()`:
```kotlin
StoryItem(title = "🔘 Foo", factory = tangemFooStoryFactory),
```
**Every title must start with an emoji** matching the component category (🔘 buttons, 🏷️ badge,
📋 row, ⏳ loader, 🔤 typography, 🔍 search, 🧭 navigation, 💀 placeholder, ✨ effects, 🪙 token…).
## Build
```bash
./gradlew :features:tester:impl:assembleGoogleDebug
```
Detekt runs via the convention plugin; keep `@file:Suppress("MagicNumber")` on showcase files that use
literal dp/colors (the button story does this). Then run the app, open Tester → Storybook → (DS
Components →) your entry, and confirm the preview + every control works.
## Checklist
- [ ] Read the real component; every meaningful parameter has a control.
- [ ] Entity in `StoryBookPage.kt` extends the correct supertype (`DsStoryBookPage` for DS, else `StoryBookPage`).
- [ ] `Build.kt` factory name is `<camelCaseName>StoryFactory`.
- [ ] Page composable shares the entity's simple name; both imported in `StoryBookScreen.kt`.
- [ ] `when` branch added in `StoryBookScreen.kt` (`is` prefix for stateful, bare for stateless).
- [ ] Registered in the correct list screen with an emoji-prefixed title.
- [ ] Live preview pinned at top, controls below (STORYBOOK.md layout rule).
- [ ] `:features:tester:impl:assembleGoogleDebug` passes.

View file

@ -10,8 +10,8 @@ fun BaseTestCase.checkSingleCurrencyMainScreen(cardTitle: String) {
step("Assert card title equal '$cardTitle'") {
onMainScreen { walletNameText.assertTextEquals(cardTitle) }
}
step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() }
step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}
step("Assert 'Sell' button is displayed") {
onMainScreen { sellButton.assertIsDisplayed() }
@ -33,9 +33,6 @@ fun BaseTestCase.checkMultiCurrencyMainScreen(
step("Assert card title equal '$cardTitle'") {
onMainScreen { walletNameText.assertTextEquals(cardTitle) }
}
step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() }
}
step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}

View file

@ -11,7 +11,7 @@ import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.openTokenDetailsFromMarketsScreen(blockchainName: String, tokenName: String) {
step("Open 'Markets' screen") {
onMainScreen { searchThroughMarketPlaceholder.performClick() }
onMainScreen { marketsSheetDragHandle.clickWithAssertion() }
waitForIdle()
}
step("Click on $blockchainName blockchain") {
@ -54,7 +54,7 @@ fun BaseTestCase.openMarketsScreen() {
synchronizeAddresses()
}
step("Open 'Markets' screen") {
onMainScreen { searchThroughMarketPlaceholder.performClick() }
onMainScreen { marketsSheetDragHandle.clickWithAssertion() }
waitForIdle()
}
}

View file

@ -2,29 +2,38 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import com.tangem.core.ui.test.BaseSearchBarTestTags
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
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.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
import com.tangem.core.res.R as CoreResR
/**
* "You receive" token chooser opened from the main-screen "Add funds" button.
* Token chooser bottom sheet opened from the main-screen "Add funds" button.
*
* After the onramp redesign this is a [BaseBottomSheetTestTags.CONTAINER] bottom sheet
* (centered title + close icon), not a full screen with a top app bar.
*/
class ChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ChooseTokenPageObject>(semanticsProvider = semanticsProvider) {
ComposeScreen<ChooseTokenPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val topAppBarTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(CoreResR.string.common_add_funds))
useUnmergedTree = true
}
val searchBar: KNode = child {
hasTestTag(BaseSearchBarTestTags.SEARCH_BAR)
useUnmergedTree = true
}
fun tokenWithTitle(tokenTitle: String): KNode = child {

View file

@ -1,12 +1,7 @@
package com.tangem.screens
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.test.*
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.getQuantityString
import com.tangem.common.extensions.hasLazyListItemPosition
@ -57,7 +52,8 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
val addFundsButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_add_funds))
hasAnyDescendant(withText(getResourceString(R.string.common_add_funds)))
useUnmergedTree = true
}
val sendButton: KNode = child {
@ -351,6 +347,11 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
useUnmergedTree = true
}
val marketsSheetDragHandle: KNode = child {
hasTestTag(MainScreenTestTags.MARKETS_SHEET_DRAG_HANDLE)
useUnmergedTree = true
}
fun tokenNetworkGroupTitle(tokenNetwork: String): KNode {
collapseHeader()
return lazyList.child {

View file

@ -74,7 +74,7 @@ class TotalBalanceUpdateTest : BaseTestCase() {
onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) }
}
step("Open 'Markets screen'") {
onMainScreen { searchThroughMarketPlaceholder.performClick() }
onMainScreen { marketsSheetDragHandle.clickWithAssertion() }
waitForIdle()
}
step("Click on $tokenTitle token") {

View file

@ -55,7 +55,7 @@ class MarketsExchangesTest : BaseTestCase() {
synchronizeAddresses()
}
step("Open 'Markets' screen") {
onMainScreen { searchThroughMarketPlaceholder.performClick() }
onMainScreen { marketsSheetDragHandle.clickWithAssertion() }
waitForIdle()
}
step("Click on '$tokenName' token") {

View file

@ -19,9 +19,11 @@ internal class AmplitudeABTestsManager(
private lateinit var client: ExperimentClient
private val logger = TangemLogger.withTag(TAG)
override fun init() {
if (::client.isInitialized) {
TangemLogger.w("AB Tests manager already initialized, skipping")
logger.w("AB Tests manager already initialized, skipping")
return
}
@ -40,7 +42,7 @@ internal class AmplitudeABTestsManager(
val allVariants = client.all()
logAllVariants(allVariants)
} catch (exception: Exception) {
TangemLogger.e("Failed to fetch AB test variants", exception)
logger.e("Failed to fetch AB test variants", exception)
}
}
}
@ -69,26 +71,25 @@ internal class AmplitudeABTestsManager(
}
private fun logAllVariants(allVariants: Map<String, com.amplitude.experiment.Variant>) {
TangemLogger.d("=".repeat(SEPARATOR_LENGTH))
TangemLogger.d("AB Tests: Fetched ${allVariants.size} variants")
TangemLogger.d("=".repeat(SEPARATOR_LENGTH))
if (allVariants.isEmpty()) {
TangemLogger.d("No variants available")
} else {
allVariants.entries.forEachIndexed { index, (key, variant) ->
TangemLogger.d("[${index + 1}/${allVariants.size}] Key: $key")
TangemLogger.d(" → Value: ${variant.value ?: "null"}")
TangemLogger.d(" → Payload: ${variant.payload ?: "null"}")
TangemLogger.d(" → Key: ${variant.key ?: "null"}")
TangemLogger.d("-".repeat(SEPARATOR_LENGTH))
val message = buildString {
appendLine("AB Tests: Fetched ${allVariants.size} variants")
if (allVariants.isEmpty()) {
append("No variants available")
} else {
allVariants.entries.forEachIndexed { index, (key, variant) ->
appendLine("[${index + 1}/${allVariants.size}] $key")
appendLine(" → value: ${variant.value ?: "null"}")
appendLine(" → key: ${variant.key ?: "null"}")
append(" → payload: ${variant.payload ?: "null"}")
if (index != allVariants.size - 1) appendLine()
}
}
}
TangemLogger.d("=".repeat(SEPARATOR_LENGTH))
logger.i(message)
}
private companion object {
const val SEPARATOR_LENGTH = 50
const val TAG = "AmplitudeABTestsManager"
}
}

View file

@ -79,6 +79,10 @@
"name": "TWI_1403_PUSH_NOTIFICATION_SETTINGS_ENABLED",
"version": "undefined"
},
{
"name": "AND_15596_ONBOARDING_PUSH_NOTIFICATION_DOUBLE_ASK_AB_ENABLED",
"version": "undefined"
},
{
"name": "AND_15310_ADD_FUNDS_STAGE1",
"version": "5.39"

View file

@ -1257,6 +1257,9 @@
<string name="push_notification_settings_title">Notification Settings</string>
<string name="push_notification_settings_transaction_alerts_subtitle">Real-time alerts for transactions, exchanges, and critical updates.</string>
<string name="push_notification_settings_transaction_alerts_title">Transaction Alerts</string>
<string name="push_notification_warning_sheet_button_enable">Enable notifications</string>
<string name="push_notification_warning_sheet_description">You won\'t receive notifications about your deposits, withdrawals, and transactions. You can turn them on anytime in Wallet Settings.</string>
<string name="push_notification_warning_sheet_title">Notifications disabled</string>
<string name="push_notifications_more_info">More info</string>
<string name="push_notifications_permission_alert_description">You can enable Notifications for Tangem in Settings.</string>
<string name="push_notifications_permission_alert_negative_button">Enable Later</string>
@ -2218,6 +2221,10 @@
<string name="warning_access_denied_message">Use %s or scan a card/ring to unlock access to your wallet</string>
<string name="warning_approval_in_progress_message">The permission-granting process is currently underway and will be completed shortly</string>
<string name="warning_approval_in_progress_title">Approval in Progress</string>
<string name="warning_backup_error_add_funds_message">This wallet has a backup issue. Contact Support to resolve it.</string>
<string name="warning_backup_error_add_funds_title">Adding funds is disabled</string>
<string name="warning_backup_error_attention_message">The backup process wasnt completed correctly, possibly due to an NFC connection issue or how the cards were tapped to the phone. Adding funds is unavailable until this is resolved.</string>
<string name="warning_backup_error_attention_title">Backup issue detected</string>
<string name="warning_backup_errors_message">Activation was not completed successfully. This may be due to an NFC issue or incorrect tapping. Please contact our Support team for assistance.</string>
<string name="warning_backup_errors_title">Activation error</string>
<string name="warning_beacon_chain_retirement_content">On December 3, 2024, the BEP-2 network was disabled by decision of the network developers and is no longer supported</string>

View file

@ -10,7 +10,9 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.MainScreenTestTags
@Composable
fun TangemBottomSheetDraggableHeaderLegacy(color: Color = TangemTheme.colors.background.primary) {
@ -37,6 +39,7 @@ fun TangemBottomSheetDraggableHeaderLegacy(color: Color = TangemTheme.colors.bac
fun TangemBottomSheetDraggableHeader() {
Box(
modifier = Modifier
.testTag(MainScreenTestTags.MARKETS_SHEET_DRAG_HANDLE)
.height(TangemTheme.dimens2.x3)
.padding(vertical = TangemTheme.dimens2.x1)
.size(

View file

@ -0,0 +1,246 @@
package com.tangem.core.ui.ds2.checkbox
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.triStateToggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_control_box_24
import com.tangem.core.ui.res.generated.icons.ic_control_box_24_filled
import com.tangem.core.ui.res.generated.icons.ic_control_checkmark_24
import com.tangem.core.ui.res.generated.icons.ic_control_indeterminate_24
/**
* Design-system v2 tri-state checkbox: an `unchecked` outline box, a `checked` filled box with a
* checkmark, or an `indeterminate` filled box with a dash.
*
* [Figma](https://www.figma.com/design/y8arHOHCa6HjMpOMJ0Ykj6/DS-64-%7C-Token-Icon?node-id=3650-628)
*
* @param state Current tri-state value. See [ToggleableState].
* @param onClick Invoked on toggle. `null` makes the checkbox non-interactive.
* @param isEnabled When `false`, the checkbox is dimmed and clicks are ignored.
* @param contentDescription Accessibility label announced by TalkBack.
* @param interactionSource Interaction source for press/focus state.
*/
@Suppress("MagicNumber", "LongMethod")
@Composable
fun TangemCheckbox(
state: ToggleableState,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
contentDescription: String? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val isPressed by interactionSource.collectIsPressedAsState()
val isFocused by interactionSource.collectIsFocusedAsState()
val contentAlpha = if (isEnabled) 1f else 0.4f // opacity/disabled
// Whole control shrinks slightly while pressed and springs back on release.
val pressScale by animateFloatAsState(
targetValue = if (isPressed) 0.92f else 1f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMediumLow),
label = "pressScale",
)
// Press fill fades in/out instead of toggling instantly.
val pressColor by animateColorAsState(
targetValue = if (isPressed) TangemTheme.colors3.interaction.press.default else Color.Transparent,
animationSpec = tween(durationMillis = 100),
label = "pressColor",
)
// Drives the filled box growing over the outline (0 = unchecked, 1 = filled).
val fillProgress by animateFloatAsState(
targetValue = if (state == ToggleableState.Off) 0f else 1f,
animationSpec = tween(durationMillis = 150),
label = "fillProgress",
)
Box(
modifier = modifier
.graphicsLayer {
alpha = contentAlpha
scaleX = pressScale
scaleY = pressScale
}
.semantics(mergeDescendants = true) {
if (!isEnabled) disabled()
contentDescription?.let { this.contentDescription = it }
}
.conditionalCompose(onClick != null) {
triStateToggleable(
state = state,
onClick = requireNotNull(onClick),
enabled = isEnabled,
role = Role.Checkbox,
interactionSource = interactionSource,
indication = null,
)
}
.size(24.dp)
.clip(CheckboxShape)
.drawBehind { drawRect(pressColor) }
.conditionalCompose(isFocused) {
border(
width = 2.dp, // border-width/md
color = TangemTheme.colors3.interaction.focusRing.brand,
shape = CheckboxShape,
)
},
contentAlignment = Alignment.Center,
) {
// Outline box — always present; the filled box grows over it.
Icon(
imageVector = Icons.ic_control_box_24,
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
)
// Filled box — fades and scales in from the center as the checkbox becomes filled.
Icon(
imageVector = Icons.ic_control_box_24_filled,
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
modifier = Modifier.graphicsLayer {
alpha = fillProgress
val markScale = lerp(start = 0.5f, stop = 1f, fraction = fillProgress)
scaleX = markScale
scaleY = markScale
},
)
// Mark — checkmark or dash pops in, and crossfades when switching between the two.
AnimatedContent(
targetState = state,
transitionSpec = {
val enter = scaleIn(
initialScale = 0.5f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow,
),
) + fadeIn()
val exit = scaleOut(targetScale = 0.5f) + fadeOut()
enter togetherWith exit
},
label = "mark",
) { current ->
when (current) {
ToggleableState.On -> Icon(
imageVector = Icons.ic_control_checkmark_24,
contentDescription = null,
tint = TangemTheme.colors3.icon.inverse,
)
ToggleableState.Indeterminate -> Icon(
imageVector = Icons.ic_control_indeterminate_24,
contentDescription = null,
tint = TangemTheme.colors3.icon.inverse,
)
ToggleableState.Off -> Box(Modifier.size(24.dp))
}
}
}
}
/**
* Boolean (checked / unchecked) overload of [TangemCheckbox] for the common two-state case.
*
* @param checked Whether the checkbox is checked.
* @param onCheckedChange Invoked with the toggled value. `null` makes the checkbox non-interactive.
*/
@Composable
fun TangemCheckbox(
checked: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
contentDescription: String? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
TangemCheckbox(
state = ToggleableState(checked),
onClick = onCheckedChange?.let { { it(!checked) } },
modifier = modifier,
isEnabled = isEnabled,
contentDescription = contentDescription,
interactionSource = interactionSource,
)
}
private val CheckboxShape = RoundedCornerShape(6.dp)
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
private fun TangemCheckboxPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
PreviewRow(label = "Enabled", isEnabled = true)
PreviewRow(label = "Disabled", isEnabled = false)
}
}
}
@Composable
private fun PreviewRow(label: String, isEnabled: Boolean) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = label,
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
ToggleableState.entries.forEach { state ->
TangemCheckbox(state = state, onClick = {}, isEnabled = isEnabled)
}
}
}
}

View file

@ -0,0 +1,214 @@
package com.tangem.core.ui.ds2.checkbox
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_control_checkmark_24
import com.tangem.core.ui.res.generated.icons.ic_control_circle_24
import com.tangem.core.ui.res.generated.icons.ic_control_circle_24_filled
/**
* Design-system v2 circular checkmark: an `unchecked` outline circle or a `checked` filled circle
* with a checkmark. Unlike [TangemCheckbox], this control is round (border-radius/full) and
* boolean-only it has no indeterminate state.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=3671-5693)
*
* @param checked Whether the checkmark is checked.
* @param onCheckedChange Invoked with the toggled value. `null` makes the control non-interactive.
* @param isEnabled When `false`, the control is dimmed and clicks are ignored.
* @param contentDescription Accessibility label announced by TalkBack.
* @param interactionSource Interaction source for press/focus state.
*/
@Suppress("MagicNumber", "LongMethod")
@Composable
fun TangemCheckmark(
checked: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
contentDescription: String? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val isPressed by interactionSource.collectIsPressedAsState()
val isFocused by interactionSource.collectIsFocusedAsState()
val contentAlpha = if (isEnabled) 1f else 0.4f
// Whole control shrinks slightly while pressed and springs back on release.
val pressScale by animateFloatAsState(
targetValue = if (isPressed) 0.92f else 1f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMediumLow),
label = "pressScale",
)
// Press fill fades in/out instead of toggling instantly.
val pressColor by animateColorAsState(
targetValue = if (isPressed) TangemTheme.colors3.interaction.press.default else Color.Transparent,
animationSpec = tween(durationMillis = 100),
label = "pressColor",
)
// Drives the filled circle growing over the outline (0 = unchecked, 1 = filled).
val fillProgress by animateFloatAsState(
targetValue = if (checked) 1f else 0f,
animationSpec = tween(durationMillis = 150),
label = "fillProgress",
)
Box(
modifier = modifier
.graphicsLayer {
alpha = contentAlpha
scaleX = pressScale
scaleY = pressScale
}
.semantics(mergeDescendants = true) {
if (!isEnabled) disabled()
contentDescription?.let { this.contentDescription = it }
}
.conditionalCompose(onCheckedChange != null) {
toggleable(
value = checked,
onValueChange = requireNotNull(onCheckedChange),
enabled = isEnabled,
role = Role.Checkbox,
interactionSource = interactionSource,
indication = null,
)
}
.size(24.dp)
.clip(CircleShape)
.drawBehind { drawRect(pressColor) }
.conditionalCompose(isFocused) {
border(
width = 2.dp,
color = TangemTheme.colors3.interaction.focusRing.brand,
shape = CircleShape,
)
},
contentAlignment = Alignment.Center,
) {
// Outline circle — always present; the filled circle grows over it.
Icon(
imageVector = Icons.ic_control_circle_24,
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
)
// Filled circle — fades and scales in from the center as the checkmark becomes filled.
Icon(
imageVector = Icons.ic_control_circle_24_filled,
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
modifier = Modifier.graphicsLayer {
alpha = fillProgress
val markScale = lerp(start = 0.5f, stop = 1f, fraction = fillProgress)
scaleX = markScale
scaleY = markScale
},
)
// Checkmark pops in and fades out as the checked state toggles.
AnimatedContent(
targetState = checked,
transitionSpec = {
val enter = scaleIn(
initialScale = 0.5f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow,
),
) + fadeIn()
val exit = scaleOut(targetScale = 0.5f) + fadeOut()
enter togetherWith exit
},
label = "mark",
) { isChecked ->
if (isChecked) {
Icon(
imageVector = Icons.ic_control_checkmark_24,
contentDescription = null,
tint = TangemTheme.colors3.icon.inverse,
)
} else {
Box(Modifier.size(24.dp))
}
}
}
}
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
private fun TangemCheckmarkPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
PreviewRow(label = "Enabled", isEnabled = true)
PreviewRow(label = "Disabled", isEnabled = false)
}
}
}
@Composable
private fun PreviewRow(label: String, isEnabled: Boolean) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = label,
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
listOf(false, true).forEach { checked ->
TangemCheckmark(checked = checked, onCheckedChange = {}, isEnabled = isEnabled)
}
}
}
}

View file

@ -15,6 +15,7 @@ object MainScreenTestTags {
const val CARD_IMAGE = "MAIN_SCREEN_CARD_IMAGE"
const val DEVICES_COUNT = "MAIN_SCREEN_DEVICES_COUNT"
const val SYNC_PROGRESS_TEXT = "MAIN_SCREEN_SYNC_PROGRESS_TEXT"
const val MARKETS_SHEET_DRAG_HANDLE = "MAIN_SCREEN_MARKETS_SHEET_DRAG_HANDLE"
const val WALLET_BALANCE = "MAIN_SCREEN_WALLET_BALANCE"
const val TOTAL_BALANCE_MENU_ITEM = "MAIN_SCREEN_TOTAL_BALANCE_MENU_ITEM"

View file

@ -3,6 +3,7 @@ package com.tangem.core.ui.utils
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalInspectionMode
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@ -15,6 +16,8 @@ import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun requestPermission(permission: String, onAllow: () -> Unit, onDeny: () -> Unit): () -> Unit {
if (LocalInspectionMode.current) return {}
val permissionState = rememberPermissionState(
permission = permission,
onPermissionResult = { isGranted ->

View file

@ -0,0 +1,7 @@
package com.tangem.features.pushnotifications
interface PushNotificationsFeatureToggles {
/** Kill switch for the onboarding "Double Ask" A/B experiment (`twi_1403_onboarding_push_notification_double_ask`). */
val isOnboardingPushDoubleAskAbEnabled: Boolean
}

View file

@ -105,4 +105,46 @@ sealed class PushNotificationAnalyticEvents(
AnalyticsParam.ERROR_TYPE to errorType,
),
)
data class WarningScreenShown(
val source: AnalyticsParam.ScreensSources,
val variant: String,
) : PushNotificationAnalyticEvents(
event = "[Warning Screen] Shown",
params = mapOf(
WARNING_SCREEN_PARAM_VARIANT to variant,
WARNING_SCREEN_PARAM_ZONE to source.toWarningScreenZone(),
),
)
data class WarningScreenEnableTapped(
val source: AnalyticsParam.ScreensSources,
val variant: String,
) : PushNotificationAnalyticEvents(
event = "[Warning Screen] Enable Tapped",
params = mapOf(
WARNING_SCREEN_PARAM_VARIANT to variant,
WARNING_SCREEN_PARAM_ZONE to source.toWarningScreenZone(),
),
)
data class WarningScreenSkipTapped(
val source: AnalyticsParam.ScreensSources,
val variant: String,
) : PushNotificationAnalyticEvents(
event = "[Warning Screen] Skip Tapped",
params = mapOf(
WARNING_SCREEN_PARAM_VARIANT to variant,
WARNING_SCREEN_PARAM_ZONE to source.toWarningScreenZone(),
),
)
}
private const val WARNING_SCREEN_PARAM_VARIANT = "variant"
private const val WARNING_SCREEN_PARAM_ZONE = "zone"
private fun AnalyticsParam.ScreensSources.toWarningScreenZone(): String = when (this) {
AnalyticsParam.ScreensSources.Onboarding -> "onboarding"
AnalyticsParam.ScreensSources.Main -> "main"
else -> value
}

View file

@ -21,6 +21,7 @@ dependencies {
implementation(deps.compose.foundation)
implementation(deps.compose.accompanist.systemUiController)
implementation(deps.compose.accompanist.permission)
implementation(deps.lifecycle.compose)
/** Other dependencies */
implementation(deps.arrow.core)
@ -34,6 +35,7 @@ dependencies {
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.utils)
implementation(projects.core.abTests)
/** Common modules */
implementation(projects.common.routing)
@ -53,4 +55,10 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Test */
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
}

View file

@ -2,8 +2,10 @@ package com.tangem.features.pushnotifications.impl
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.NavigationBar3ButtonsScrim
@ -12,6 +14,8 @@ import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import com.tangem.features.pushnotifications.api.PushNotificationsParams
import com.tangem.features.pushnotifications.impl.model.PushNotificationsModel
import com.tangem.features.pushnotifications.impl.presentation.ui.PushNotificationsScreen
import com.tangem.features.pushnotifications.impl.presentation.ui.PushNotificationsUM
import com.tangem.features.pushnotifications.impl.presentation.ui.PushNotificationsDoubleAskSheetState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -26,12 +30,21 @@ internal class DefaultPushNotificationsComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val activity = LocalContext.current.findActivity()
val isDoubleAskSheetShown by model.isDoubleAskSheetShown.collectAsStateWithLifecycle()
BackHandler(onBack = { activity.finish() })
NavigationBar3ButtonsScrim()
PushNotificationsScreen(
isPushNotificationSettingsEnabled = model.isPushNotificationSettingsEnabled,
state = PushNotificationsUM(
isPushNotificationSettingsEnabled = model.isPushNotificationSettingsEnabled,
doubleAskSheet = PushNotificationsDoubleAskSheetState(
isShown = isDoubleAskSheetShown,
onEnableClick = model::onDoubleAskEnableClick,
onSkipClick = model::onDoubleAskSkipClick,
onDismiss = model::onDoubleAskDismiss,
),
),
onAllowClick = model::onAllowClick,
onLaterClick = model::onLaterClick,
onAllowPermission = model::onAllowPermission,

View file

@ -0,0 +1,16 @@
package com.tangem.features.pushnotifications.impl
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.pushnotifications.PushNotificationsFeatureToggles
import javax.inject.Inject
internal class DefaultPushNotificationsFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : PushNotificationsFeatureToggles {
override val isOnboardingPushDoubleAskAbEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(
FeatureToggles.AND_15596_ONBOARDING_PUSH_NOTIFICATION_DOUBLE_ASK_AB_ENABLED,
)
}

View file

@ -1,10 +1,12 @@
package com.tangem.features.pushnotifications.impl.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.pushnotifications.PushNotificationsFeatureToggles
import com.tangem.features.pushnotifications.api.PushNotificationsBottomSheetComponent
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import com.tangem.features.pushnotifications.impl.DefaultPushNotificationsBottomSheetComponent
import com.tangem.features.pushnotifications.impl.DefaultPushNotificationsComponent
import com.tangem.features.pushnotifications.impl.DefaultPushNotificationsFeatureToggles
import com.tangem.features.pushnotifications.impl.model.PushNotificationsModel
import dagger.Binds
import dagger.Module
@ -12,6 +14,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@ -29,4 +32,8 @@ internal interface PushNotificationsModule {
@IntoMap
@ClassKey(PushNotificationsModel::class)
fun bindModel(model: PushNotificationsModel): Model
@Binds
@Singleton
fun bindFeatureToggles(impl: DefaultPushNotificationsFeatureToggles): PushNotificationsFeatureToggles
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.pushnotifications.impl.domain
enum class DoubleAskVariant(val key: String) {
Off(key = "control"),
On(key = "treatment"),
;
companion object {
fun fromKey(value: String): DoubleAskVariant =
entries.firstOrNull { it.key.equals(value, ignoreCase = true) } ?: Off
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.features.pushnotifications.impl.domain
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.features.pushnotifications.PushNotificationsFeatureToggles
import javax.inject.Inject
class GetPushNotificationsDoubleAskVariantUseCase @Inject constructor(
private val pushNotificationsFeatureToggles: PushNotificationsFeatureToggles,
private val abTestsManager: ABTestsManager,
) {
operator fun invoke(): DoubleAskVariant {
if (!pushNotificationsFeatureToggles.isOnboardingPushDoubleAskAbEnabled) {
return DoubleAskVariant.Off
}
val variant = abTestsManager.getValue(AMPLITUDE_ID, DoubleAskVariant.Off.key)
return DoubleAskVariant.fromKey(variant)
}
private companion object {
const val AMPLITUDE_ID = "twi_1403_onboarding_push_notification_double_ask"
}
}

View file

@ -8,4 +8,10 @@ internal interface PushNotificationsClickIntents {
fun onAllowPermission()
fun onDenyPermission()
fun onDoubleAskEnableClick()
fun onDoubleAskSkipClick()
fun onDoubleAskDismiss()
}

View file

@ -18,9 +18,14 @@ import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase
import com.tangem.features.pushnotifications.api.PushNotificationsParams
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.features.pushnotifications.impl.domain.GetPushNotificationsDoubleAskVariantUseCase
import com.tangem.features.pushnotifications.impl.domain.DoubleAskVariant
import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -39,6 +44,7 @@ internal class PushNotificationsModel @Inject constructor(
private val setAllWalletPushNotificationPreferences: SetAllWalletPushNotificationPreferencesUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
private val accountsCRUDRepository: AccountsCRUDRepository,
private val getPushNotificationsDoubleAskVariantUseCase: GetPushNotificationsDoubleAskVariantUseCase,
) : Model(), PushNotificationsClickIntents {
val params: PushNotificationsParams = paramsContainer.require()
@ -51,6 +57,11 @@ internal class PushNotificationsModel @Inject constructor(
AppRoute.PushNotification.Source.Onboarding -> AnalyticsParam.ScreensSources.Onboarding
}
private val _isDoubleAskSheetShown = MutableStateFlow(false)
val isDoubleAskSheetShown: StateFlow<Boolean> = _isDoubleAskSheetShown.asStateFlow()
private var resolvedVariant: String = DoubleAskVariant.Off.key
init {
analyticHandler.send(PushNotificationAnalyticEvents.NotificationsScreenOpened(source))
}
@ -64,16 +75,48 @@ internal class PushNotificationsModel @Inject constructor(
override fun onLaterClick() {
analyticHandler.send(PushNotificationAnalyticEvents.ButtonLater(source))
modelScope.launch {
neverRequestPermissionUseCase(PUSH_PERMISSION)
neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION)
if (params.isBottomSheet) {
notificationsRepository.setUserAllowToSubscribeOnPushNotifications(false)
} else {
params.nextRoute?.let { appRouter.push(it) }
}
params.modelCallbacks.onDenySystemPermission()
if (isOnWalletScreen()) {
modelScope.launch { proceedAfterLater() }
return
}
val variant = getPushNotificationsDoubleAskVariantUseCase()
resolvedVariant = variant.key
if (variant == DoubleAskVariant.On) {
analyticHandler.send(PushNotificationAnalyticEvents.WarningScreenShown(source, resolvedVariant))
_isDoubleAskSheetShown.value = true
} else {
modelScope.launch { proceedAfterLater() }
}
}
override fun onDoubleAskEnableClick() {
analyticHandler.send(PushNotificationAnalyticEvents.WarningScreenEnableTapped(source, resolvedVariant))
modelScope.launch {
notificationsRepository.setUserAllowToSubscribeOnPushNotifications(true)
}
}
override fun onDoubleAskSkipClick() {
analyticHandler.send(PushNotificationAnalyticEvents.WarningScreenSkipTapped(source, resolvedVariant))
modelScope.launch { proceedAfterLater() }
}
override fun onDoubleAskDismiss() {
_isDoubleAskSheetShown.value = false
}
private fun isOnWalletScreen(): Boolean =
params.isBottomSheet && params.source == AppRoute.PushNotification.Source.Main
private suspend fun proceedAfterLater() {
neverRequestPermissionUseCase(PUSH_PERMISSION)
neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION)
if (params.isBottomSheet) {
notificationsRepository.setUserAllowToSubscribeOnPushNotifications(false)
} else {
params.nextRoute?.let { appRouter.push(it) }
}
params.modelCallbacks.onDenySystemPermission()
}
override fun onAllowPermission() {

View file

@ -1,20 +1,49 @@
package com.tangem.features.pushnotifications.impl.presentation.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet
import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM
import com.tangem.core.ui.components.bottomsheets.message.icon
import com.tangem.core.ui.components.bottomsheets.message.infoBlock
import com.tangem.core.ui.components.bottomsheets.message.messageBottomSheetUM
import com.tangem.core.ui.components.bottomsheets.message.onClick
import com.tangem.core.ui.components.bottomsheets.message.primaryButton
import com.tangem.core.ui.components.bottomsheets.message.secondaryButton
import com.tangem.core.ui.components.showcase.Showcase
import com.tangem.core.ui.components.showcase.model.ShowcaseButtonModel
import com.tangem.core.ui.components.showcase.model.ShowcaseItemModel
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.requestPermission
import com.tangem.feature.pushnotifications.impl.R
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import kotlinx.collections.immutable.persistentListOf
/**
* Holds the treatment-variant "Double Ask" bottom sheet state and callbacks for the onboarding soft-ask.
*/
@Immutable
internal data class PushNotificationsDoubleAskSheetState(
val isShown: Boolean,
val onEnableClick: () -> Unit,
val onSkipClick: () -> Unit,
val onDismiss: () -> Unit,
)
@Immutable
internal data class PushNotificationsUM(
val isPushNotificationSettingsEnabled: Boolean,
val doubleAskSheet: PushNotificationsDoubleAskSheetState,
)
@Composable
internal fun PushNotificationsScreen(
isPushNotificationSettingsEnabled: Boolean,
state: PushNotificationsUM,
onAllowClick: () -> Unit,
onLaterClick: () -> Unit,
onAllowPermission: () -> Unit,
@ -26,12 +55,12 @@ internal fun PushNotificationsScreen(
permission = PUSH_PERMISSION,
)
val argumentTwoTitleRes = if (isPushNotificationSettingsEnabled) {
val argumentTwoTitleRes = if (state.isPushNotificationSettingsEnabled) {
R.string.user_push_notification_agreement_argument_two_title_v2
} else {
R.string.user_push_notification_agreement_argument_two_title
}
val argumentTwoSubtitleRes = if (isPushNotificationSettingsEnabled) {
val argumentTwoSubtitleRes = if (state.isPushNotificationSettingsEnabled) {
R.string.user_push_notification_agreement_argument_two_subtitle_v2
} else {
R.string.user_push_notification_agreement_argument_two_subtitle
@ -65,4 +94,84 @@ internal fun PushNotificationsScreen(
),
modifier = Modifier.systemBarsPadding(),
)
if (state.doubleAskSheet.isShown) {
PushNotificationsDoubleAskBottomSheet(
onEnableClick = {
state.doubleAskSheet.onEnableClick()
requestPushPermission()
},
onSkipClick = state.doubleAskSheet.onSkipClick,
onDismiss = state.doubleAskSheet.onDismiss,
)
}
}
@Composable
private fun PushNotificationsDoubleAskBottomSheet(
onEnableClick: () -> Unit,
onSkipClick: () -> Unit,
onDismiss: () -> Unit,
) {
MessageBottomSheet(
state = messageBottomSheetUM {
infoBlock {
icon(com.tangem.core.ui.R.drawable.ic_attention_default_24) {
type = MessageBottomSheetUM.Icon.Type.Attention
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Attention
}
title = resourceReference(R.string.push_notification_warning_sheet_title)
body = resourceReference(R.string.push_notification_warning_sheet_description)
}
primaryButton {
text = resourceReference(R.string.push_notification_warning_sheet_button_enable)
onClick { onEnableClick() }
}
secondaryButton {
text = resourceReference(R.string.common_skip)
onClick { onSkipClick() }
}
},
onDismissRequest = onDismiss,
)
}
private fun previewState(isDoubleAskShown: Boolean) = PushNotificationsUM(
isPushNotificationSettingsEnabled = true,
doubleAskSheet = PushNotificationsDoubleAskSheetState(
isShown = isDoubleAskShown,
onEnableClick = {},
onSkipClick = {},
onDismiss = {},
),
)
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_PushNotificationsScreen() {
TangemThemePreview {
PushNotificationsScreen(
state = previewState(isDoubleAskShown = false),
onAllowClick = {},
onLaterClick = {},
onAllowPermission = {},
onDenyPermission = {},
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_PushNotificationsScreen_DoubleAsk() {
TangemThemePreview {
PushNotificationsScreen(
state = previewState(isDoubleAskShown = true),
onAllowClick = {},
onLaterClick = {},
onAllowPermission = {},
onDenyPermission = {},
)
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.features.pushnotifications.impl.domain
import com.google.common.truth.Truth.assertThat
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.features.pushnotifications.PushNotificationsFeatureToggles
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
internal class GetPushNotificationsDoubleAskVariantUseCaseTest {
private val featureToggles: PushNotificationsFeatureToggles = mockk()
private val abTestsManager: ABTestsManager = mockk()
private val useCase = GetPushNotificationsDoubleAskVariantUseCase(
pushNotificationsFeatureToggles = featureToggles,
abTestsManager = abTestsManager,
)
@Test
fun `GIVEN toggle disabled WHEN invoke THEN returns Off and AB not queried`() {
every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns false
val result = useCase()
assertThat(result).isEqualTo(DoubleAskVariant.Off)
verify(exactly = 0) { abTestsManager.getValue(any(), any()) }
}
@Test
fun `GIVEN toggle enabled AND AB returns treatment WHEN invoke THEN returns On`() {
every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns true
every { abTestsManager.getValue(KEY, "control") } returns "treatment"
val result = useCase()
assertThat(result).isEqualTo(DoubleAskVariant.On)
verify(exactly = 1) { abTestsManager.getValue(KEY, "control") }
}
@Test
fun `GIVEN toggle enabled AND AB returns control WHEN invoke THEN returns Off`() {
every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns true
every { abTestsManager.getValue(KEY, "control") } returns "control"
assertThat(useCase()).isEqualTo(DoubleAskVariant.Off)
}
@Test
fun `GIVEN toggle enabled AND AB returns unknown WHEN invoke THEN returns Off`() {
every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns true
every { abTestsManager.getValue(KEY, "control") } returns "unexpected_value"
assertThat(useCase()).isEqualTo(DoubleAskVariant.Off)
}
private companion object {
const val KEY = "twi_1403_onboarding_push_notification_double_ask"
}
}

View file

@ -0,0 +1,204 @@
package com.tangem.features.pushnotifications.impl.model
import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase
import com.tangem.domain.settings.NeverRequestPermissionUseCase
import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks
import com.tangem.features.pushnotifications.api.PushNotificationsParams
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
import com.tangem.features.pushnotifications.impl.domain.GetPushNotificationsDoubleAskVariantUseCase
import com.tangem.features.pushnotifications.impl.domain.DoubleAskVariant
import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class PushNotificationsModelTest {
private val neverRequestPermissionUseCase: NeverRequestPermissionUseCase = mockk(relaxed = true)
private val neverToInitiallyAskPermissionUseCase: NeverToInitiallyAskPermissionUseCase = mockk(relaxed = true)
private val appRouter: AppRouter = mockk(relaxed = true)
private val analyticHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val notificationsRepository: NotificationsRepository = mockk(relaxed = true)
private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles = mockk(relaxed = true)
private val setAllWalletPushNotificationPreferences: SetAllWalletPushNotificationPreferencesUseCase =
mockk(relaxed = true)
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true)
private val accountsCRUDRepository: AccountsCRUDRepository = mockk(relaxed = true)
private val getDoubleAskVariantUseCase: GetPushNotificationsDoubleAskVariantUseCase = mockk()
private val modelCallbacks: PushNotificationsModelCallbacks = mockk(relaxed = true)
@BeforeEach
fun setUp() {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.Off
}
@Test
fun `GIVEN onboarding treatment WHEN onLaterClick THEN double ask shown and not proceeded`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
val model = createModel(testScope = this)
advanceUntilIdle()
model.onLaterClick()
advanceUntilIdle()
assertThat(model.isDoubleAskSheetShown.value).isTrue()
verify {
analyticHandler.send(
match<PushNotificationAnalyticEvents.WarningScreenShown> {
it.variant == DoubleAskVariant.On.key
},
)
}
coVerify(exactly = 0) { neverRequestPermissionUseCase(any()) }
verify(exactly = 0) { modelCallbacks.onDenySystemPermission() }
}
@Test
fun `GIVEN onboarding control WHEN onLaterClick THEN proceeds without double ask`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.Off
val model = createModel(testScope = this)
advanceUntilIdle()
model.onLaterClick()
advanceUntilIdle()
assertThat(model.isDoubleAskSheetShown.value).isFalse()
coVerify { neverRequestPermissionUseCase(any()) }
coVerify { neverToInitiallyAskPermissionUseCase(any()) }
verify { modelCallbacks.onDenySystemPermission() }
verify(exactly = 0) {
analyticHandler.send(match<PushNotificationAnalyticEvents.WarningScreenShown> { true })
}
}
@Test
fun `GIVEN main bottom sheet WHEN onLaterClick THEN double ask not shown and variant not resolved`() = runTest {
val model = createModel(
testScope = this,
isBottomSheet = true,
source = AppRoute.PushNotification.Source.Main,
)
advanceUntilIdle()
model.onLaterClick()
advanceUntilIdle()
assertThat(model.isDoubleAskSheetShown.value).isFalse()
verify(exactly = 0) { getDoubleAskVariantUseCase() }
verify { modelCallbacks.onDenySystemPermission() }
}
@Test
fun `GIVEN double ask shown WHEN onDoubleAskEnableClick THEN enable tapped sent and not proceeded`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
val model = createModel(testScope = this)
advanceUntilIdle()
model.onLaterClick()
advanceUntilIdle()
model.onDoubleAskEnableClick()
advanceUntilIdle()
verify {
analyticHandler.send(match<PushNotificationAnalyticEvents.WarningScreenEnableTapped> { true })
}
coVerify { notificationsRepository.setUserAllowToSubscribeOnPushNotifications(true) }
verify(exactly = 0) { modelCallbacks.onDenySystemPermission() }
}
@Test
fun `GIVEN double ask shown WHEN onDoubleAskSkipClick THEN event sent and proceeded`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
val model = createModel(testScope = this)
advanceUntilIdle()
model.onLaterClick()
advanceUntilIdle()
model.onDoubleAskSkipClick()
advanceUntilIdle()
verify {
analyticHandler.send(match<PushNotificationAnalyticEvents.WarningScreenSkipTapped> { true })
}
coVerify { neverRequestPermissionUseCase(any()) }
verify { modelCallbacks.onDenySystemPermission() }
}
@Test
fun `GIVEN double ask shown WHEN onDoubleAskDismiss THEN sheet hidden and not proceeded`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
val model = createModel(testScope = this)
advanceUntilIdle()
model.onLaterClick()
advanceUntilIdle()
model.onDoubleAskDismiss()
advanceUntilIdle()
assertThat(model.isDoubleAskSheetShown.value).isFalse()
verify(exactly = 0) { modelCallbacks.onDenySystemPermission() }
verify(exactly = 0) {
analyticHandler.send(match<PushNotificationAnalyticEvents.WarningScreenSkipTapped> { true })
}
}
private fun createModel(
testScope: TestScope,
isBottomSheet: Boolean = false,
source: AppRoute.PushNotification.Source = AppRoute.PushNotification.Source.Onboarding,
paramsContainer: ParamsContainer = MutableParamsContainer(
value = PushNotificationsParams(
isBottomSheet = isBottomSheet,
nextRoute = null,
modelCallbacks = modelCallbacks,
source = source,
),
),
): PushNotificationsModel {
return PushNotificationsModel(
paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
neverRequestPermissionUseCase = neverRequestPermissionUseCase,
neverToInitiallyAskPermissionUseCase = neverToInitiallyAskPermissionUseCase,
appRouter = appRouter,
analyticHandler = analyticHandler,
notificationsRepository = notificationsRepository,
pushNotificationSettingsFeatureToggles = pushNotificationSettingsFeatureToggles,
setAllWalletPushNotificationPreferences = setAllWalletPushNotificationPreferences,
userWalletsListRepository = userWalletsListRepository,
accountsCRUDRepository = accountsCRUDRepository,
getPushNotificationsDoubleAskVariantUseCase = getDoubleAskVariantUseCase,
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}

202
features/swap-v2/CLAUDE.md Normal file
View file

@ -0,0 +1,202 @@
# Swap V2 / Send-with-Swap Feature
This module implements **Send-with-Swap (SvS)**: a send transaction where the sent token is swapped
(CEX) to a different *receive* token at a *destination address* in one flow. The user picks a receive
token, enters amounts (with Fixed/Float rate), enters a destination address (+ memo for memo-networks),
reviews on Confirm, and sends.
> There is **no standalone token↔token swap UI** in this module — that lives in `features/swap/`
> (see `features/swap/CLAUDE.md`). swap-v2 is the redesigned **send-with-swap** flow plus its shared
> amount/provider/notifications subscreens, built on the **send-v2** subcomponents.
## Module Structure
```
features/swap-v2/
api/ — com.tangem.features.swap.v2.api
SendWithSwapComponent (+ Params/Factory), SwapFeatureToggles,
SwapAmountUpdateTrigger, subcomponents/, choosetoken/
impl/ — com.tangem.features.swap.v2.impl (android-library + Hilt/kapt)
sendviaswap/ — SvS flow root, model, routes, confirm/, success/, analytics/
amount/ — swap amount screen (model, transformers, converters, entity, ui)
chooseprovider/— provider selector bottom sheet
choosetoken/ — receive-token / network selection
notifications/ — swap-specific notifications (price impact, express errors)
common/ — ConfirmData, SwapAlertFactory, SwapUtils, entities (ConfirmUM, SwapQuoteUM)
di/ — Hilt modules
```
**Package naming:** API = `com.tangem.features.swap.v2.api`, Impl = `com.tangem.features.swap.v2.impl`.
Consistent `.v2` segment (unlike the legacy `features/swap` which uses `feature.swap` for impl).
**Build commands:**
```bash
./gradlew :features:swap-v2:impl:compileDebugKotlin
./gradlew :features:swap-v2:api:compileDebugKotlin
./gradlew :features:swap-v2:impl:testDebugUnitTest
./gradlew :features:swap-v2:impl:detekt
```
## The SvS Flow (sendviaswap/)
### Entry: SendWithSwapComponent (api) / DefaultSendWithSwapComponent (impl)
- `SendWithSwapComponent.Params`: `userWalletId`, `currency` (the **FROM** token), `callback`.
- `DefaultSendWithSwapComponent` (`impl/.../sendviaswap/DefaultSendWithSwapComponent.kt`) owns an inner
`StackNavigation<SendWithSwapRoute>` + `InnerRouter`, creates `SendWithSwapModel` via
`getOrCreateModel`, and a `childStack` rendering Amount/Destination/Confirm/Success.
### Routes: SendWithSwapRoute
`impl/.../sendviaswap/SendWithSwapRoute.kt` — sealed `Route`, every entry has `isEditMode: Boolean`:
- `Amount(isEditMode)` — implements `SwapAmountRoute`
- `Destination(isEditMode)` — implements send-v2 `DestinationRoute`
- `Confirm` (object, `isEditMode = false`)
- `Success` (object, `isEditMode = false`)
`isEditMode` distinguishes the **linear** forward flow (`Amount → Destination → Confirm`) from
**re-editing** a step *from Confirm* (`showEditAmount`/`showEditDestination` push the step with
`isEditMode = true`; `onNextClick` then **pops** back to Confirm instead of advancing).
### Parent model: SendWithSwapModel
`impl/.../sendviaswap/model/SendWithSwapModel.kt`. `@ModelScoped`. Implements three child callbacks
(`SwapAmountComponent.ModelCallback`, `SendDestinationComponent.ModelCallback`,
`SendWithSwapConfirmComponent.ModelCallback`). Holds the **aggregate** state:
- `uiState: StateFlow<SendWithSwapUM>``{ amountUM, destinationUM, feeSelectorUM, confirmUM, navigationUM }`
- `currentRoute: MutableStateFlow<SendWithSwapRoute>`
- `primaryCryptoCurrencyStatusFlow`, `primaryFeePaidCurrencyStatusFlow`, `accountFlow`,
`isAccountModeFlow`, `isBalanceHiddenFlow` — read-only sources passed down to children as params.
Child→parent merge callbacks:
- `onAmountResult(amountUM)``uiState.copy(amountUM = …)`
- `onDestinationResult(destinationUM)``uiState.copy(destinationUM = …)`
- `onResult(route, sendWithSwapUM)`**`if (currentRoute.value == route) uiState.value = …`** (full replace,
route-guarded; used by Confirm to publish its full state back up)
- `onNavigationResult(navigationUM)` → drives the shared footer button/app-bar.
### childStack subscription = the state-sync mechanism (READ THIS)
`DefaultSendWithSwapComponent.init { childStack.subscribe(CREATE_DESTROY) { stack → componentScope.launch { … } } }`:
on every active-child change it **pushes the parent's current snapshot into the newly-active child** and
then emits the new route:
```kotlin
when (active) {
is SwapAmountComponent -> active.updateState(uiState.value.amountUM)
is SendDestinationComponent -> active.updateState(uiState.value.destinationUM) // screen
is SendWithSwapConfirmComponent ->
if (model.currentRoute.value.isEditMode) active.updateState(uiState.value) // ← gated!
}
model.currentRoute.emit(stack.active.configuration) // emitted AFTER the isEditMode read
```
The `isEditMode` check intentionally reads the **previous** route (the emit happens afterwards) so it is
true exactly when returning to a *reused* Confirm from an edit step. In the linear flow Confirm is
re-created fresh from `params.sendWithSwapUM`, so no re-push is needed.
### Confirm: SendWithSwapConfirmComponent / SendWithSwapConfirmModel
`impl/.../sendviaswap/confirm/`. The Confirm screen embeds **read-only blocks** reused from send-v2:
- `SwapAmountBlockComponent` (swap-v2)
- `SendDestinationBlockComponent` (send-v2) — shows address + memo, click → `showEditDestination`
- `FeeSelectorBlockComponent` (send-v2)
- `SendNotificationsComponent` (send-v2) + `SwapNotificationsComponent` (swap-v2)
`SendWithSwapConfirmModel`:
- `uiState: StateFlow<SendWithSwapUM>` seeded from `params.sendWithSwapUM`.
- `confirmData: ConfirmData` (computed) — extracts `enteredFromAmount/enteredToAmount`,
`enteredDestination`, `enteredMemo`, `fee`, statuses, quote, rateType, amountType, priceImpact from
`uiState`; this is what the transaction + notifications are built from.
- `onFeeResult/onAmountResult/onDestinationResult` — block callbacks copy into `uiState`.
- `updateState(sendWithSwapUM)` — full replace (used by the edit-mode re-push).
- `configConfirmNavigation``combine(uiState, currentRoute).filter { route is Confirm }`
`callback.onResult(Confirm, state.copy(navigationUM = …))` (publishes confirm state up to the parent).
- Sending: `SwapTransactionSender` (CEX only; DEX/DEX_BRIDGE/ONRAMP rejected). Success →
`SendWithSwapConfirmSentStateTransformer` + `router.replaceAll(Success)`.
### Success: SendWithSwapSuccessComponent
`impl/.../sendviaswap/success/` — renders `ConfirmUM.Success` (tx date, explorer url, provider, swap data).
## Amount screen (amount/)
- `SwapAmountComponent` / `SwapAmountModel` (`amount/model/SwapAmountModel.kt`, ~big orchestrator).
- State `SwapAmountUM` (`amount/entity/SwapAmountUM.kt`): `Empty(swapDirection)` | `Content` with
`primaryAmount`/`secondaryAmount` fields, `primary/secondaryCryptoCurrencyStatus`,
`swapRateType: ExpressRateType` (Fixed|Float), `swapQuotes`, `selectedQuote: SwapQuoteUM`, `priceImpact`.
- Quotes are loaded periodically via a task scheduler and through `GetSwapQuoteUseCase`.
- Transformers (`amount/model/transformers/`): `SwapAmountValueChangeTransformer`,
`SwapAmountSelectQuoteTransformer`, `SwapAmountSetQuotesTransformer`,
`SwapAmountChangeAmountTypeTransformer`, `SwapAmount{Reduce*,Max,Paste,…}Transformer`, applied via
`uiState.transformerUpdate(…)`.
- **Fixed vs Float:** `SwapAmountType.To` must use `ExpressRateType.Fixed` (the float API can't target a
to-amount); `SwapAmountType.From` uses `Float`. Provider filtering checks
`provider.rateTypes.contains(rateType)` before requesting a quote.
## Choose provider / token, Notifications
- `chooseprovider/``SwapChooseProviderComponent`/`Model`, bottom-sheet provider list (converters
`SwapProviderListItemConverter`, `SwapProviderStateConverter`).
- `choosetoken/` — receive-token + network selection (`SwapChooseTokenNetworkModel`, transformers).
- `notifications/``SwapNotificationsComponent`/`Model`, driven by `SwapNotificationsUpdateTrigger`/
`…Listener`; produces price-impact / express-error / destination-tag-required notifications.
## Reused send-v2 subcomponents (API boundary)
SvS consumes these `features/send-v2/api` contracts (impl injected via DI):
- `SendDestinationComponent.Factory` — the navigable **address/memo screen**.
- `SendDestinationBlockComponent.Factory` — the **read-only block** on Confirm.
- `FeeSelectorBlockComponent.Factory` + `FeeSelectorReloadTrigger`.
- `SendNotificationsComponent.Factory` + `SendNotificationsUpdateTrigger`/`…Listener`.
- Entities: `DestinationUM`, `FeeSelectorUM`, `NavigationUM`, `PredefinedValues`.
The shared destination model is **`features/send-v2/.../subcomponents/destination/model/SendDestinationModel.kt`**.
Its `updateState(destinationUM)` does `if (Content && isInitialized) _uiState.value = destinationUM`
(StateFlow dedups equal values). `saveResult()` (push to the parent callback) runs on Next, on
auto-next, and **on back only when `!route.isEditMode`**.
## DI modules (di/ and per-subpackage di/)
| Module | Scope | Provides |
|---|---|---|
| `SwapFeatureModules` | Singleton | `SwapFeatureToggles` |
| `SendWithSwapModule` | Singleton + Model | `SendWithSwapComponent.Factory`, `SendWithSwapModel` |
| `SwapAmountModule` | Singleton + Model | `SwapAmountModel`, `SwapAmountUpdateTrigger/Listener`, `SwapAmountReduceTrigger/Listener` |
| `SendWithSwapConfirmModule` | Model | `SendWithSwapConfirmModel` |
| `SwapChooseProviderModule` | Model | `SwapChooseProviderModel` |
| `SwapChooseTokenModule` | Singleton + Model | choose-token factories/model |
| `SwapNotificationsModule` | Singleton + Model | `SwapNotificationsModel`, `SwapNotificationsUpdateTrigger/Listener` |
## Analytics
- `SendWithSwapAnalyticEvents` (`sendviaswap/analytics/`) — `ConfirmationScreenOpened`,
`AmountScreenOpened`, `TransactionScreenOpened`, `OnSendClick`, `NoticeFixedRate/FloatRate`,
`Error{InsufficientBalance,MinAmount,MaxAmount,ExpressQuote}`, `HighPriceImpact`, `TradeTooLarge`;
category = `CommonSendAnalyticEvents.SEND_CATEGORY`. `ExpressRateType.toAnalyticsRateType()` maps rate.
- `SwapAmountAnalyticEvents` + `SwapAmountAnalyticsSender` (`amount/analytics/`) — provider selector events.
## State-management patterns & gotchas
- **Transformer pattern:** `uiState.transformerUpdate(SomeTransformer(...))`; transformers early-return
`prevState` if not the expected subtype (`as? Content ?: return prevState`).
- **Three+ StateFlows hold the destination at once.** The memo/address lives in: the navigable
Destination **screen** model (#A), the parent `SendWithSwapModel.uiState.destinationUM` (#B), the
`SendWithSwapConfirmModel.uiState.destinationUM` (#C), and the Confirm-embedded destination **block**
model (#D, what Confirm actually displays). They are synced by **snapshot copies** (`updateState`,
`onResult`, `onDestinationResult`) over `StateFlow.value =` (which **dedups by `equals`**), plus the
block's self-feeding `init { uiState.onEach { onResult(it) } }`. This is fragile — see [REDACTED_TASK_KEY]
("floating memo": an edit on #A intermittently fails to reach #D). Prefer a single source of truth
when touching this area; do **not** assume an `updateState` re-push actually emits (equal value = no-op).
- **Edit-mode back does not persist.** Leaving an edit step via the back arrow / system back skips
`saveResult()` (`SendDestinationModel.configDestinationNavigation`, `if (!route.isEditMode)`), so the
parent keeps the pre-edit value. The footer "Continue"/"Next" button always persists. This is shared
by regular Send + NFT Send + SvS.
- **`onResult` is route-guarded.** `SendWithSwapModel.onResult` only applies when
`currentRoute.value == route`, which protects against late/stale Confirm emissions overwriting the
parent after navigating away. Keep that guard if you refactor.
- **`currentRoute.emit` runs at the END of the subscribe coroutine**, so the `isEditMode` re-push gate
reads the *previous* route. Relies on `componentScope` launches being serialized (main dispatcher).
- **CEX-only.** `SwapTransactionSender` rejects DEX/DEX_BRIDGE/ONRAMP. Destination address for CEX is
only known after exchange-data, so confirm notifications pass `destinationAddress = null` for the
send-notifications path.
## Testing
JUnit 5 + MockK + Turbine + Truth (see project `.claude/rules/unit-testing.md`). Feature-model tests
build the heavy graph with relaxed mocks and a single `StandardTestDispatcher`; drive with
`advanceUntilIdle()` and `model.onDestroy()`. For SvS state-sync regressions, prefer parent-model
(`SendWithSwapModel`) tests asserting that an edit propagated through `onDestinationResult` is the value
that `uiState.destinationUM` ends up holding across an edit→confirm round trip.

View file

@ -102,6 +102,8 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
if (model.currentRoute.value.isEditMode) {
activeComponent.updateState(model.uiState.value)
}
// Re-sync destination from parent on Confirm entry, bypassing the edit-mode gate ([REDACTED_TASK_KEY]).
activeComponent.updateDestinationState(model.uiState.value.destinationUM)
val fromCurrency = params.currency
val content = model.uiState.value.amountUM as? SwapAmountUM.Content ?: return@launch
val toCurrency = content.secondaryCryptoCurrencyStatus?.currency ?: return@launch

View file

@ -21,6 +21,7 @@ import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.params.FeeSelectorParams.*
import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.swap.v2.impl.amount.SwapAmountBlockComponent
import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams
import com.tangem.features.swap.v2.impl.common.SwapUtils.SEND_WITH_SWAP_PROVIDER_TYPES
@ -40,7 +41,7 @@ import kotlinx.coroutines.flow.*
internal class SendWithSwapConfirmComponent @AssistedInject constructor(
@Assisted private val appComponentContext: AppComponentContext,
@Assisted private val params: Params,
sendDestinationBlockComponent: SendDestinationBlockComponent.Factory,
sendDestinationBlockComponentFactory: SendDestinationBlockComponent.Factory,
feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory,
sendNotificationsComponentFactory: SendNotificationsComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
@ -69,7 +70,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
onClick = model::showEditAmount,
)
private val sendDestinationBlockComponent = sendDestinationBlockComponent.create(
private val sendDestinationBlockComponent = sendDestinationBlockComponentFactory.create(
context = child("sendWithSwapConfirmDestinationBlock"),
params = SendDestinationComponentParams.DestinationBlockParams(
state = model.uiState.value.destinationUM,
@ -81,7 +82,8 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
predefinedValues = PredefinedValues.Empty,
isAllowSelfSend = true,
),
onResult = model::onDestinationResult,
// No feedback: the read-only block is driven one-way by the model.uiState collector ([REDACTED_TASK_KEY]).
onResult = {},
onClick = model::showEditDestination,
)
@ -151,15 +153,29 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
val confirmUM = state.confirmUM as? ConfirmUM.Content
blockClickEnableFlow.value = confirmUM?.isTransactionInProcess == false
}.launchIn(componentScope)
// Single source of truth: the block always mirrors the model's authoritative destinationUM.
model.uiState
.map { it.destinationUM }
.distinctUntilChanged()
.onEach(sendDestinationBlockComponent::updateState)
.launchIn(componentScope)
}
fun updateState(sendWithSwapUM: SendWithSwapUM) {
amountBlockComponent.updateState(sendWithSwapUM.amountUM)
sendDestinationBlockComponent.updateState(sendWithSwapUM.destinationUM)
feeSelectorBlockComponent.updateState(sendWithSwapUM.feeSelectorUM)
model.updateState(sendWithSwapUM)
}
// Re-sync destination from parent on Confirm entry, bypassing the edit-mode gate; Empty only occurs on
// reset (which leaves Confirm), so only Content is applied ([REDACTED_TASK_KEY]).
fun updateDestinationState(destinationUM: DestinationUM) {
if (destinationUM is DestinationUM.Content && destinationUM != model.uiState.value.destinationUM) {
model.onDestinationResult(destinationUM)
}
}
@Composable
override fun Content(modifier: Modifier) {
val sendWithSwapUM by model.uiState.collectAsStateWithLifecycle()

View file

@ -94,6 +94,7 @@ internal class TangemPayCardPageComponent @AssistedInject constructor(
)
TangemPayCardDetailsInnerRoute.LimitSetupSuccess -> TangemPayCardLimitSetupSuccessComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
isRedesignEnabled = tangemPayFeatureToggles.isRedesignEnabled,
)
}

View file

@ -21,9 +21,16 @@ internal class TangemPayCardLimitSetupComponent(
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
BackHandler(onBack = router::pop)
TangemPayCardLimitSetupScreen(
state = state,
modifier = modifier,
)
if (model.inRedesignEnabled()) {
TangemPayCardLimitSetupScreenV2(
state = state,
modifier = modifier,
)
} else {
TangemPayCardLimitSetupScreen(
state = state,
modifier = modifier,
)
}
}
}

View file

@ -22,6 +22,7 @@ import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute
@ -47,6 +48,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor(
private val setTangemPayCardLimitUseCase: SetTangemPayCardLimitUseCase,
private val uiMessageSender: UiMessageSender,
private val analytics: AnalyticsEventHandler,
private val featureToggles: TangemPayFeatureToggles,
) : Model() {
private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require()
@ -78,6 +80,8 @@ internal class TangemPayCardLimitSetupModel @Inject constructor(
observeCardState()
}
fun inRedesignEnabled(): Boolean = featureToggles.isRedesignEnabled
private fun observeCardState() {
paymentAccountStatusSupplier.invoke(userWalletId)
.map { it.value }

View file

@ -0,0 +1,186 @@
package com.tangem.features.tangempay.limit.setup
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.shimmers.TextShimmer
import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.utils.rememberDecimalFormat
import com.tangem.features.tangempay.details.impl.R
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun TangemPayCardLimitSetupScreenV2(state: TangemPayCardLimitSetupUM, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier,
topBar = {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
startContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_back_28),
onClick = state.onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
title = resourceReference(R.string.tangempay_card_page_daily_limit_title),
)
},
containerColor = TangemTheme.colors3.bg.secondary,
) { scaffoldPaddings ->
Content(
state = state,
modifier = Modifier.padding(scaffoldPaddings),
)
}
}
@Composable
private fun Content(state: TangemPayCardLimitSetupUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x3)
.imePadding(),
) {
AmountBlock(
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4),
state = state,
)
Spacer(modifier = Modifier.weight(1f))
PresetsRow(presets = state.presets)
TangemButton(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens2.x4)
.padding(top = TangemTheme.dimens2.x3),
size = TangemButton.Size.X12,
text = resourceReference(R.string.tangempay_daily_limit_set_button),
onClick = state.onSubmitClick,
isEnabled = state.isSubmitButtonEnabled,
isLoading = state.isSubmitButtonLoading,
)
}
}
@Composable
private fun AmountBlock(state: TangemPayCardLimitSetupUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens2.x8),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x3),
) {
Text(
text = state.subtitle.resolveReference(),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.tertiary,
)
if (state.isInitialDataLoading) {
TextShimmer(
style = TextShimmerStyle.HEADING_MEDIUM,
text = "$ 10000",
radius = TangemTheme.dimens2.x25,
)
} else {
AmountTextField(
value = state.amountFieldModel.value,
decimals = state.amountFieldModel.decimals,
onValueChange = state.amountFieldModel.onValueChange,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
visualTransformation = AmountVisualTransformation(
decimals = state.amountFieldModel.decimals,
symbol = state.currencyCode,
currencyCode = state.currencyCode,
decimalFormat = rememberDecimalFormat(),
symbolColor = if (state.amountFieldModel.value.isBlank()) {
TangemTheme.colors3.text.tertiary
} else {
TangemTheme.colors3.text.primary
},
),
textStyle = TangemTheme.typography3.display.medium.copy(
textAlign = TextAlign.Center,
),
isAutoResize = true,
backgroundColor = TangemTheme.colors3.bg.secondary,
)
}
}
}
@Composable
private fun PresetsRow(presets: ImmutableList<TangemPayCardLimitSetupUM.LimitPresetUM>) {
if (presets.isEmpty()) return
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens2.x3, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
presets.forEach { preset ->
PresetChip(
preset = preset,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun PresetChip(preset: TangemPayCardLimitSetupUM.LimitPresetUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(RoundedCornerShape(14.dp))
.background(TangemTheme.colors3.bg.tertiary)
.clickable(onClick = preset.onClick)
.padding(horizontal = TangemTheme.dimens2.x5, vertical = TangemTheme.dimens2.x1)
.wrapContentHeight(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier
.padding(vertical = 1.dp)
.fillMaxWidth(),
text = preset.label,
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.primary,
textAlign = TextAlign.Center,
maxLines = 1,
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun preview() = TangemThemePreviewRedesign {
TangemPayCardLimitSetupScreenV2(
state = TangemPayCardLimitSetupUM.stub(),
)
}

View file

@ -8,16 +8,24 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute
internal class TangemPayCardLimitSetupSuccessComponent(
private val isRedesignEnabled: Boolean,
appComponentContext: AppComponentContext,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
@Composable
override fun Content(modifier: Modifier) {
BackHandler(onBack = ::backToDetails)
TangemPayCardLimitSetupSuccessScreen(
modifier = modifier,
onDoneClick = ::backToDetails,
)
if (isRedesignEnabled) {
TangemPayCardLimitSetupSuccessScreenV2(
modifier = modifier,
onDoneClick = ::backToDetails,
)
} else {
TangemPayCardLimitSetupSuccessScreen(
modifier = modifier,
onDoneClick = ::backToDetails,
)
}
}
private fun backToDetails() {

View file

@ -0,0 +1,28 @@
package com.tangem.features.tangempay.limit.setup
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.ui.components.TangemPaySuccessScreenWrapper
@Composable
internal fun TangemPayCardLimitSetupSuccessScreenV2(onDoneClick: () -> Unit, modifier: Modifier = Modifier) {
TangemPaySuccessScreenWrapper(
modifier = modifier,
title = resourceReference(R.string.tangempay_card_page_daily_limit_success_title),
subtitle = resourceReference(R.string.tangempay_card_page_daily_limit_success_description),
buttonText = resourceReference(R.string.common_done),
onButtonClick = onDoneClick,
)
}
@Preview(showBackground = true)
@Composable
private fun Preview() {
TangemThemePreviewRedesign {
TangemPayCardLimitSetupSuccessScreenV2(onDoneClick = {})
}
}

View file

@ -1,92 +1,25 @@
package com.tangem.features.tangempay.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_success_24
import com.tangem.core.ui.test.TangemPayTestTags
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.ui.components.TangemPaySuccessScreenWrapper
private const val BG_GREEN_COLOR = 0xFF9FC824
@Suppress("MagicNumber")
@Composable
internal fun TangemPayChangePinCodeSuccessScreenV2(onClose: () -> Unit, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize()) {
Box(
modifier = Modifier
.matchParentSize()
.blur(192.dp)
.drawBehind {
val w = size.width
drawRect(
brush = Brush.radialGradient(
colors = listOf(
Color(BG_GREEN_COLOR),
Color.Transparent,
),
center = Offset(w / 2f, -w * .1f),
radius = w + w * .2f,
tileMode = TileMode.Clamp,
),
)
},
)
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(top = 72.dp, start = 24.dp, end = 24.dp),
) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.ic_success_24,
tint = TangemTheme.colors3.icon.primary,
contentDescription = null,
)
SpacerH(TangemTheme.dimens2.x4)
Text(
modifier = Modifier.testTag(TangemPayTestTags.PIN_SUCCESS_TITLE),
text = stringResourceSafe(R.string.tangempay_card_details_change_pin_success_title),
style = TangemTheme.typography3.heading.medium,
color = TangemTheme.colors3.text.primary,
)
Text(
modifier = Modifier.testTag(TangemPayTestTags.PIN_SUCCESS_DESCRIPTION),
text = stringResourceSafe(R.string.tangempay_card_details_change_pin_success_description),
style = TangemTheme.typography3.heading.medium,
color = TangemTheme.colors3.text.secondary,
)
SpacerHMax()
TangemButton(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens2.x3),
onClick = onClose,
size = TangemButton.Size.X12,
text = resourceReference(R.string.common_close),
)
}
}
TangemPaySuccessScreenWrapper(
modifier = modifier,
title = resourceReference(R.string.tangempay_card_details_change_pin_success_title),
subtitle = resourceReference(R.string.tangempay_card_details_change_pin_success_description),
buttonText = resourceReference(R.string.common_close),
onButtonClick = onClose,
titleTestTag = TangemPayTestTags.PIN_SUCCESS_TITLE,
subtitleTestTag = TangemPayTestTags.PIN_SUCCESS_DESCRIPTION,
)
}
@Preview(showBackground = true)

View file

@ -0,0 +1,113 @@
package com.tangem.features.tangempay.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_success_24
import com.tangem.features.tangempay.details.impl.R
private const val DEFAULT_FADE_COLOR = 0xFF9FC824
private val BlurRadius = 192.dp
@Suppress("MagicNumber")
@Composable
internal fun TangemPaySuccessScreenWrapper(
title: TextReference,
subtitle: TextReference,
buttonText: TextReference,
onButtonClick: () -> Unit,
modifier: Modifier = Modifier,
fadeColor: Color = Color(DEFAULT_FADE_COLOR),
titleTestTag: String? = null,
subtitleTestTag: String? = null,
) {
Box(modifier = modifier.fillMaxSize()) {
Box(
modifier = Modifier
.matchParentSize()
.blur(BlurRadius)
.drawBehind {
val w = size.width
drawRect(
brush = Brush.radialGradient(
colors = listOf(
fadeColor,
Color.Transparent,
),
center = Offset(w / 2f, -w * .1f),
radius = w + w * .2f,
tileMode = TileMode.Clamp,
),
)
},
)
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(top = 72.dp, start = 24.dp, end = 24.dp),
) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.ic_success_24,
tint = TangemTheme.colors3.icon.primary,
contentDescription = null,
)
SpacerH(TangemTheme.dimens2.x4)
Text(
modifier = titleTestTag?.let { Modifier.testTag(it) } ?: Modifier,
text = title.resolveReference(),
style = TangemTheme.typography3.heading.medium,
color = TangemTheme.colors3.text.primary,
)
Text(
modifier = subtitleTestTag?.let { Modifier.testTag(it) } ?: Modifier,
text = subtitle.resolveReference(),
style = TangemTheme.typography3.heading.medium,
color = TangemTheme.colors3.text.secondary,
)
SpacerHMax()
TangemButton(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens2.x3),
onClick = onButtonClick,
size = TangemButton.Size.X12,
text = buttonText,
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun Preview() {
TangemThemePreviewRedesign {
TangemPaySuccessScreenWrapper(
title = resourceReference(R.string.tangempay_card_details_change_pin_success_title),
subtitle = resourceReference(R.string.tangempay_card_details_change_pin_success_description),
buttonText = resourceReference(R.string.common_close),
onButtonClick = {},
)
}
}

View file

@ -9,16 +9,12 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardLimit
import com.tangem.domain.models.pay.TangemPayCardLimitData
import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.domain.models.pay.*
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
@ -42,6 +38,7 @@ internal class TangemPayCardLimitSetupModelTest {
private val setLimitUseCase: SetTangemPayCardLimitUseCase = mockk(relaxed = true)
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
private val analytics: AnalyticsEventHandler = mockk(relaxed = true)
private val featureToggles: TangemPayFeatureToggles = mockk()
private val initialCard = TangemPayCard(
id = cardId,
@ -104,6 +101,7 @@ internal class TangemPayCardLimitSetupModelTest {
setTangemPayCardLimitUseCase = setLimitUseCase,
uiMessageSender = uiMessageSender,
analytics = analytics,
featureToggles = featureToggles,
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.feature.tester.presentation.storybook.entity
import androidx.compose.runtime.Immutable
import androidx.compose.ui.state.ToggleableState
import com.tangem.core.ui.ds.badge.TangemBadgeColor
import com.tangem.core.ui.ds.field.search.TangemFieldShape
import com.tangem.core.ui.ds.message.TangemMessageEffect
@ -318,6 +319,20 @@ internal data class TangemSearchStory(
}
}
internal data class TangemCheckboxV2Story(
val state: ToggleableState,
val isEnabled: Boolean,
val onStateChange: (ToggleableState) -> Unit,
val onEnabledToggle: () -> Unit,
) : DsStoryBookPage
internal data class TangemCheckmarkStory(
val isChecked: Boolean,
val isEnabled: Boolean,
val onCheckedChange: (Boolean) -> Unit,
val onEnabledToggle: () -> Unit,
) : DsStoryBookPage
internal data class TangemBadgeV2Story(
val variant: TangemBadge.Variant,
val status: TangemBadge.Status,

View file

@ -17,6 +17,8 @@ import com.tangem.feature.tester.presentation.storybook.entity.DsComponentsListS
import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.badge.tangemBadgeV2StoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.button.tangemButtonStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.checkbox.tangemCheckboxV2StoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.checkmark.tangemCheckmarkStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.fade.tangemFadeStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.loader.tangemLoaderStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.row.tangemRowStoryFactory
@ -30,6 +32,8 @@ private fun buildDsStories() = listOf(
DsStoryItem(title = "⏳ TangemLoader", factory = tangemLoaderStoryFactory),
DsStoryItem(title = "🔘 TangemButton", factory = tangemButtonStoryFactory),
DsStoryItem(title = "🏷️ TangemBadge", factory = tangemBadgeV2StoryFactory),
DsStoryItem(title = "☑️ TangemCheckbox", factory = tangemCheckboxV2StoryFactory),
DsStoryItem(title = "⭕ TangemCheckmark", factory = tangemCheckmarkStoryFactory),
DsStoryItem(title = "📋 TangemRow", factory = tangemRowStoryFactory),
DsStoryItem(title = "🔎 TangemSearch", factory = tangemSearchStoryFactory),
DsStoryItem(title = "✨ TangemShimmer", factory = tangemShimmerStoryFactory),

View file

@ -0,0 +1,22 @@
package com.tangem.feature.tester.presentation.storybook.page.ds.checkbox
import androidx.compose.ui.state.ToggleableState
import com.tangem.feature.tester.presentation.storybook.entity.TangemCheckboxV2Story
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
internal fun StateUpdater<TangemCheckboxV2Story>.build(): TangemCheckboxV2Story {
return TangemCheckboxV2Story(
state = ToggleableState.Off,
isEnabled = true,
onStateChange = { state ->
updateStory { it.copy(state = state) }
},
onEnabledToggle = {
updateStory { it.copy(isEnabled = !it.isEnabled) }
},
)
}
internal val tangemCheckboxV2StoryFactory
get() = storyPageFactory(StateUpdater<TangemCheckboxV2Story>::build)

View file

@ -0,0 +1,190 @@
@file:Suppress("MagicNumber")
package com.tangem.feature.tester.presentation.storybook.page.ds.checkbox
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds2.checkbox.TangemCheckbox
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tester.presentation.storybook.entity.TangemCheckboxV2Story
@Composable
internal fun TangemCheckboxV2Story(state: TangemCheckboxV2Story, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary)
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Preview stays pinned at the top.
ComponentPreview(state = state)
// Only the controls scroll.
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
StateSelector(selected = state.state, onSelect = state.onStateChange)
Toggles(state = state)
}
}
}
@Composable
private fun ComponentPreview(state: TangemCheckboxV2Story) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors3.bg.primary)
.padding(vertical = 48.dp),
) {
// Clicking the live checkbox cycles Off -> On -> Indeterminate -> Off.
TangemCheckbox(
state = state.state,
onClick = { state.onStateChange(state.state.next()) },
isEnabled = state.isEnabled,
modifier = Modifier.scale(2f),
)
}
}
private fun ToggleableState.next(): ToggleableState = when (this) {
ToggleableState.Off -> ToggleableState.On
ToggleableState.On -> ToggleableState.Indeterminate
ToggleableState.Indeterminate -> ToggleableState.Off
}
@Composable
private fun StateSelector(selected: ToggleableState, onSelect: (ToggleableState) -> Unit) {
Section(label = "State") {
ChipGrid(
items = ToggleableState.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun Toggles(state: TangemCheckboxV2Story) {
Section(label = "Flags") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ToggleRow(label = "isEnabled", checked = state.isEnabled, onToggle = state.onEnabledToggle)
}
}
}
@Composable
private fun Section(label: String, content: @Composable () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = label,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
content()
}
}
@Composable
private fun <T> ChipGrid(items: List<T>, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) {
val shape = RoundedCornerShape(50)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(shape)
.background(TangemTheme.colors2.surface.level2)
.border(
width = 1.dp,
color = TangemTheme.colors2.border.neutral.secondary,
shape = shape,
)
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items.forEach { item ->
Chip(
label = label(item),
selected = isSelected(item),
onClick = { onSelect(item) },
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val chipShape = RoundedCornerShape(50)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.clip(chipShape)
.background(
if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2,
)
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 4.dp),
) {
Text(
text = label,
style = TangemTheme.typography.caption2,
color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary,
)
}
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(TangemTheme.colors2.surface.level2)
.clickable(onClick = onToggle)
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = label,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = if (checked) "ON" else "OFF",
style = TangemTheme.typography.caption2,
color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary,
)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.feature.tester.presentation.storybook.page.ds.checkmark
import com.tangem.feature.tester.presentation.storybook.entity.TangemCheckmarkStory
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
internal fun StateUpdater<TangemCheckmarkStory>.build(): TangemCheckmarkStory {
return TangemCheckmarkStory(
isChecked = false,
isEnabled = true,
onCheckedChange = { checked ->
updateStory { it.copy(isChecked = checked) }
},
onEnabledToggle = {
updateStory { it.copy(isEnabled = !it.isEnabled) }
},
)
}
internal val tangemCheckmarkStoryFactory
get() = storyPageFactory(StateUpdater<TangemCheckmarkStory>::build)

View file

@ -0,0 +1,125 @@
@file:Suppress("MagicNumber")
package com.tangem.feature.tester.presentation.storybook.page.ds.checkmark
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds2.checkbox.TangemCheckmark
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tester.presentation.storybook.entity.TangemCheckmarkStory
@Composable
internal fun TangemCheckmarkStory(state: TangemCheckmarkStory, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary)
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Preview stays pinned at the top.
ComponentPreview(state = state)
// Only the controls scroll.
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Toggles(state = state)
}
}
}
@Composable
private fun ComponentPreview(state: TangemCheckmarkStory) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors3.bg.primary)
.padding(vertical = 48.dp),
) {
// Clicking the live checkmark toggles checked on/off.
TangemCheckmark(
checked = state.isChecked,
onCheckedChange = state.onCheckedChange,
isEnabled = state.isEnabled,
modifier = Modifier.scale(2f),
)
}
}
@Composable
private fun Toggles(state: TangemCheckmarkStory) {
Section(label = "Flags") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ToggleRow(
label = "checked",
checked = state.isChecked,
onToggle = { state.onCheckedChange(!state.isChecked) },
)
ToggleRow(label = "isEnabled", checked = state.isEnabled, onToggle = state.onEnabledToggle)
}
}
}
@Composable
private fun Section(label: String, content: @Composable () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = label,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
content()
}
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(TangemTheme.colors2.surface.level2)
.clickable(onClick = onToggle)
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = label,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = if (checked) "ON" else "OFF",
style = TangemTheme.typography.caption2,
color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary,
)
}
}

View file

@ -39,6 +39,8 @@ import com.tangem.feature.tester.presentation.storybook.page.deviceicon.DeviceIc
import com.tangem.feature.tester.presentation.storybook.page.ds.DsComponentsListStory
import com.tangem.feature.tester.presentation.storybook.page.ds.badge.TangemBadgeV2Story
import com.tangem.feature.tester.presentation.storybook.page.ds.button.TangemButtonStory
import com.tangem.feature.tester.presentation.storybook.page.ds.checkbox.TangemCheckboxV2Story
import com.tangem.feature.tester.presentation.storybook.page.ds.checkmark.TangemCheckmarkStory
import com.tangem.feature.tester.presentation.storybook.page.ds.fade.TangemFadeStory
import com.tangem.feature.tester.presentation.storybook.page.ds.loader.TangemLoaderStory
import com.tangem.feature.tester.presentation.storybook.page.ds.row.TangemRowStory
@ -92,6 +94,8 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier)
is TangemLoaderStory -> TangemLoaderStory(state = storyState)
is TangemButtonStory -> TangemButtonStory(state = storyState)
is TangemBadgeV2Story -> TangemBadgeV2Story(state = storyState)
is TangemCheckboxV2Story -> TangemCheckboxV2Story(state = storyState)
is TangemCheckmarkStory -> TangemCheckmarkStory(state = storyState)
is TangemRowStory -> TangemRowStory(state = storyState)
is TangemSearchStory -> TangemSearchStory(state = storyState)
is TangemShimmerStory -> TangemShimmerStory(state = storyState)

View file

@ -208,7 +208,7 @@ private fun WalletContent2(
val pullToRefreshState = rememberPullToRefreshState()
Box(
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.hazeSourceTangem(zIndex = -2f),
@ -307,11 +307,15 @@ private fun WalletContent2(
TangemCollapsingTopBar(
state = behavior.state,
collapsingPart = {
val balanceBlockHeight = with(LocalDensity.current) {
-behavior.state.heightOffsetLimit.toDp()
}
WalletBalance(
behavior = behavior,
walletBalanceUM = currentWallet.walletsBalanceUM,
buttons = currentWallet.buttons,
isBalanceHidden = state.isHidingMode,
modifier = Modifier.height(balanceBlockHeight),
onSubtitleBottomChange = { newValue ->
if (pullToRefreshContentOffset == 0.dp && newValue > subtitleBottom) {
subtitleBottom = newValue
@ -348,11 +352,12 @@ private fun WalletContent2(
MarketsTooltip(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 24.dp)
.padding(bottom = 8.dp)
.padding(horizontal = 12.dp)
.fillMaxWidth(),
isVisible = state.showMarketsOnboarding,
availableHeight = LocalWindowSize.current.height,
availableHeight = maxHeight,
sheetTopInset = TangemTheme.dimens2.x3,
bottomSheetState = bottomSheetState,
onCloseClick = state.onDismissMarketsTooltip,
)

View file

@ -10,19 +10,17 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideIn
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
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.shadow
import androidx.compose.ui.geometry.*
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
@ -31,12 +29,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.*
import com.tangem.core.ui.components.sheetscaffold.TangemSheetState
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@ -53,10 +46,11 @@ internal fun MarketsTooltip(
bottomSheetState: TangemSheetState,
isVisible: Boolean,
onCloseClick: () -> Unit,
sheetTopInset: Dp,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val tooltipOffset by remember {
val tooltipOffset by remember(availableHeight, sheetTopInset) {
derivedStateOf {
val bottomSheetOffset = try {
// Can throw exception during the first composition
@ -64,8 +58,7 @@ internal fun MarketsTooltip(
} catch (e: Exception) {
0.dp
}
bottomSheetOffset - availableHeight
bottomSheetOffset + sheetTopInset - availableHeight
}
}

View file

@ -28,7 +28,7 @@ enum class BuildType(
BuildConfigField.LogEnabled(isEnabled = true),
BuildConfigField.TesterMenuAvailability(isEnabled = true),
BuildConfigField.MockDataSource(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = true),
),
),
@ -74,7 +74,7 @@ enum class BuildType(
BuildConfigField.LogEnabled(isEnabled = true),
BuildConfigField.TesterMenuAvailability(isEnabled = true),
BuildConfigField.MockDataSource(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = true),
),
),
@ -114,7 +114,7 @@ enum class BuildType(
BuildConfigField.LogEnabled(isEnabled = false),
BuildConfigField.TesterMenuAvailability(isEnabled = false),
BuildConfigField.MockDataSource(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = true),
),
),
;