Updated on 2026-08-14
This commit is contained in:
commit
49515ccbd7
52 changed files with 2394 additions and 167 deletions
163
.claude/rules/codestyle/design-system.md
Normal file
163
.claude/rules/codestyle/design-system.md
Normal 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.
|
||||
150
.claude/skills/add-storybook-component/SKILL.md
Normal file
150
.claude/skills/add-storybook-component/SKILL.md
Normal 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 A–E 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.
|
||||
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class MarketsExchangesTest : BaseTestCase() {
|
|||
synchronizeAddresses()
|
||||
}
|
||||
step("Open 'Markets' screen") {
|
||||
onMainScreen { searchThroughMarketPlaceholder.performClick() }
|
||||
onMainScreen { marketsSheetDragHandle.clickWithAssertion() }
|
||||
waitForIdle()
|
||||
}
|
||||
step("Click on '$tokenName' token") {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 wasn’t 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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -8,4 +8,10 @@ internal interface PushNotificationsClickIntents {
|
|||
fun onAllowPermission()
|
||||
|
||||
fun onDenyPermission()
|
||||
|
||||
fun onDoubleAskEnableClick()
|
||||
|
||||
fun onDoubleAskSkipClick()
|
||||
|
||||
fun onDoubleAskDismiss()
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
202
features/swap-v2/CLAUDE.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ internal class TangemPayCardPageComponent @AssistedInject constructor(
|
|||
)
|
||||
TangemPayCardDetailsInnerRoute.LimitSetupSuccess -> TangemPayCardLimitSetupSuccessComponent(
|
||||
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
|
||||
isRedesignEnabled = tangemPayFeatureToggles.isRedesignEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 = {})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue