From 740da6cddd63bde331f5e58723b67138df1743b1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 18:21:20 +0300 Subject: [PATCH 1/6] Updated on 2026-08-14 --- .claude/rules/codestyle/design-system.md | 163 ++++++++++++ .../skills/add-storybook-component/SKILL.md | 150 +++++++++++ .../core/ui/ds2/checkbox/TangemCheckbox.kt | 246 ++++++++++++++++++ .../core/ui/ds2/checkbox/TangemCheckmark.kt | 214 +++++++++++++++ .../presentation/storybook}/STORYBOOK.md | 0 .../storybook/entity/StoryBookPage.kt | 15 ++ .../page/ds/DsComponentsListScreen.kt | 4 + .../storybook/page/ds/checkbox/Build.kt | 22 ++ .../page/ds/checkbox/TangemCheckboxV2Story.kt | 190 ++++++++++++++ .../storybook/page/ds/checkmark/Build.kt | 21 ++ .../page/ds/checkmark/TangemCheckmarkStory.kt | 125 +++++++++ .../storybook/ui/StoryBookScreen.kt | 4 + 12 files changed, 1154 insertions(+) create mode 100644 .claude/rules/codestyle/design-system.md create mode 100644 .claude/skills/add-storybook-component/SKILL.md create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/checkbox/TangemCheckbox.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/checkbox/TangemCheckmark.kt rename features/tester/{ => impl/src/main/java/com/tangem/feature/tester/presentation/storybook}/STORYBOOK.md (100%) create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkbox/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkbox/TangemCheckboxV2Story.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkmark/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkmark/TangemCheckmarkStory.kt diff --git a/.claude/rules/codestyle/design-system.md b/.claude/rules/codestyle/design-system.md new file mode 100644 index 0000000000..f41727ac79 --- /dev/null +++ b/.claude/rules/codestyle/design-system.md @@ -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.`, folder + `core/ui/.../ds2//`. The component name is `Tangem`. +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` (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.kt`, private `TangemInternal.kt`, tokens `TangemExt.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`, **not** as a top-level type. This keeps a single +`Tangem.Variant` / `Tangem.Size` / `Tangem.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//`, package `com.tangem.core.ui.ds2.`. +- [ ] Named `Tangem`; 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` (not a set of boolean flags). +- [ ] All public types (enums, statuses, constants) declared inside the `object Tangem`. +- [ ] 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.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. \ No newline at end of file diff --git a/.claude/skills/add-storybook-component/SKILL.md b/.claude/skills/add-storybook-component/SKILL.md new file mode 100644 index 0000000000..1a85fa944c --- /dev/null +++ b/.claude/skills/add-storybook-component/SKILL.md @@ -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 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//` | `DsStoryBookPage` | +| **2. Other components** | Anything else (legacy/cross-cutting components, backgrounds, effects, typography demos) | `ui/StoryBookListScreen.kt` → `buildStories()` | `page//` | `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//` + (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/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 `/Build.kt` + +Stateful — uses `storyPageFactory` + `StateUpdater`: +```kotlin +internal fun StateUpdater.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::build) +``` +Stateless: `internal val tangemFooStoryFactory: StoryPageFactory = StoryPageFactory { TangemFooStory }` + +### C. Create `/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 `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. \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/checkbox/TangemCheckbox.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/checkbox/TangemCheckbox.kt new file mode 100644 index 0000000000..89665b8586 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/checkbox/TangemCheckbox.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/checkbox/TangemCheckmark.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/checkbox/TangemCheckmark.kt new file mode 100644 index 0000000000..ec4d732723 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/checkbox/TangemCheckmark.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/features/tester/STORYBOOK.md b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/STORYBOOK.md similarity index 100% rename from features/tester/STORYBOOK.md rename to features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/STORYBOOK.md diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt index 80da0f8c8d..1d461a65c9 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt @@ -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, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt index 5fb1754b5a..1083082fea 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt @@ -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), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkbox/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkbox/Build.kt new file mode 100644 index 0000000000..d607c7ebfa --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkbox/Build.kt @@ -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.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::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkbox/TangemCheckboxV2Story.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkbox/TangemCheckboxV2Story.kt new file mode 100644 index 0000000000..55b8fc8205 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkbox/TangemCheckboxV2Story.kt @@ -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 ChipGrid(items: List, 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, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkmark/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkmark/Build.kt new file mode 100644 index 0000000000..b91360bb21 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkmark/Build.kt @@ -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.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::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkmark/TangemCheckmarkStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkmark/TangemCheckmarkStory.kt new file mode 100644 index 0000000000..385ca5cdae --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/checkmark/TangemCheckmarkStory.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt index 0ca00f04d3..fb4ee3a9a7 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt @@ -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) From c443fa43c325d480707d70284564ad1e8dbcac4e Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 17:43:38 +0200 Subject: [PATCH 2/6] Updated on 2026-08-14 --- .../components/TangemPayCardPageComponent.kt | 1 + .../setup/TangemPayCardLimitSetupComponent.kt | 15 +- .../setup/TangemPayCardLimitSetupModel.kt | 4 + .../setup/TangemPayCardLimitSetupScreenV2.kt | 186 ++++++++++++++++++ ...TangemPayCardLimitSetupSuccessComponent.kt | 16 +- .../TangemPayCardLimitSetupSuccessScreenV2.kt | 28 +++ .../TangemPayChangePinCodeSuccessScreenV2.kt | 87 +------- .../TangemPaySuccessScreenWrapper.kt | 113 +++++++++++ .../setup/TangemPayCardLimitSetupModelTest.kt | 10 +- 9 files changed, 369 insertions(+), 91 deletions(-) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreenV2.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessScreenV2.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPaySuccessScreenWrapper.kt diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt index ccae6d4b0d..5e1b79099b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt @@ -94,6 +94,7 @@ internal class TangemPayCardPageComponent @AssistedInject constructor( ) TangemPayCardDetailsInnerRoute.LimitSetupSuccess -> TangemPayCardLimitSetupSuccessComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + isRedesignEnabled = tangemPayFeatureToggles.isRedesignEnabled, ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupComponent.kt index 2916fd93ed..be1c3c8179 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupComponent.kt @@ -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, + ) + } } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt index 9b33e3f33a..1a0f3d994c 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt @@ -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 } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreenV2.kt new file mode 100644 index 0000000000..66b6c26bc8 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreenV2.kt @@ -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) { + 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(), + ) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessComponent.kt index a7c95a2eb2..33d095ce49 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessComponent.kt @@ -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() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessScreenV2.kt new file mode 100644 index 0000000000..81c924bbc5 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessScreenV2.kt @@ -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 = {}) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreenV2.kt index b9669aef3c..6a17a5b56c 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreenV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreenV2.kt @@ -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) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPaySuccessScreenWrapper.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPaySuccessScreenWrapper.kt new file mode 100644 index 0000000000..bcddc5af74 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPaySuccessScreenWrapper.kt @@ -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 = {}, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt index e192e54e5f..9017b9d11e 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt @@ -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, ) } From 5fddb62d4e58a1971bde2e5271ee1408c7ad404f Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 17:44:06 +0200 Subject: [PATCH 3/6] Updated on 2026-08-14 --- .../presentation/wallet/ui/WalletScreen2.kt | 11 +++++--- .../wallet/ui/components/MarketsTooltip.kt | 25 +++++++------------ 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt index eaaa3ee797..304af18a54 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt @@ -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, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/MarketsTooltip.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/MarketsTooltip.kt index b5d5ac4170..0d17924be7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/MarketsTooltip.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/MarketsTooltip.kt @@ -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 } } From bf27373667f6345f41de5e54361c83def6bd875f Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 20:46:41 +0500 Subject: [PATCH 4/6] Updated on 2026-08-14 --- .../scenarios/CheckMainScreenScenarios.kt | 7 ++----- .../com/tangem/scenarios/MarketsScenarios.kt | 4 ++-- .../com/tangem/screens/ChooseTokenPageObject.kt | 17 +++++++++++++---- .../com/tangem/screens/MainScreenPageObject.kt | 15 ++++++++------- .../tests/balance/TotalBalanceUpdateTest.kt | 2 +- .../tests/markets/MarketsExchangesTest.kt | 2 +- .../TangemBottomSheetDraggableHeaderLegacy.kt | 3 +++ .../tangem/core/ui/test/MainScreenTestTags.kt | 1 + 8 files changed, 31 insertions(+), 20 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt index cdc6c984c2..665515e2cb 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt @@ -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() } } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt index ac596e4ede..df5b36514e 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt @@ -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() } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenPageObject.kt index 28ebe26a4a..c2dd74ddd5 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenPageObject.kt @@ -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(semanticsProvider = semanticsProvider) { + ComposeScreen( + 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 { diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 5868e2121f..4faf866ff0 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -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 { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt index 61318f6706..efdc72799c 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt @@ -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") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt index cef6601e65..4cc58d517c 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt @@ -55,7 +55,7 @@ class MarketsExchangesTest : BaseTestCase() { synchronizeAddresses() } step("Open 'Markets' screen") { - onMainScreen { searchThroughMarketPlaceholder.performClick() } + onMainScreen { marketsSheetDragHandle.clickWithAssertion() } waitForIdle() } step("Click on '$tokenName' token") { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt index 6a03c12c26..18b34425eb 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt @@ -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( diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt index bd535e8260..2d9787a059 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt @@ -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" From fb69f0df7cb86286242c6081565f8106a1578a96 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 19:51:45 +0400 Subject: [PATCH 5/6] Updated on 2026-08-14 --- features/swap-v2/CLAUDE.md | 202 ++++++++++++++++++ .../DefaultSendWithSwapComponent.kt | 2 + .../confirm/SendWithSwapConfirmComponent.kt | 24 ++- 3 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 features/swap-v2/CLAUDE.md diff --git a/features/swap-v2/CLAUDE.md b/features/swap-v2/CLAUDE.md new file mode 100644 index 0000000000..0f80e7d910 --- /dev/null +++ b/features/swap-v2/CLAUDE.md @@ -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` + `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` — `{ amountUM, destinationUM, feeSelectorUM, confirmUM, navigationUM }` +- `currentRoute: MutableStateFlow` +- `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` 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. \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt index d6e438be46..f34b1ad38e 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt @@ -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 diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index 467a6f134b..424c5d9d76 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -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() From 935a967deb5dbe6218e5586999c371f2c188282d Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 18:54:12 +0300 Subject: [PATCH 6/6] Updated on 2026-08-14 --- .../manager/impl/AmplitudeABTestsManager.kt | 35 +-- .../configs/feature_toggles_config.json | 4 + core/res/src/main/res/values/strings.xml | 7 + .../tangem/core/ui/utils/RequestPermission.kt | 3 + .../PushNotificationsFeatureToggles.kt | 7 + .../PushNotificationAnalyticEvents.kt | 42 ++++ .../push-notifications/impl/build.gradle.kts | 8 + .../impl/DefaultPushNotificationsComponent.kt | 15 +- .../DefaultPushNotificationsFeatureToggles.kt | 16 ++ .../impl/di/PushNotificationsModule.kt | 7 + .../impl/domain/DoubleAskVariant.kt | 12 ++ ...ushNotificationsDoubleAskVariantUseCase.kt | 23 ++ .../model/PushNotificationsClickIntents.kt | 6 + .../impl/model/PushNotificationsModel.kt | 61 +++++- .../ui/PushNotificationsScreen.kt | 115 +++++++++- ...otificationsDoubleAskVariantUseCaseTest.kt | 61 ++++++ .../impl/model/PushNotificationsModelTest.kt | 204 ++++++++++++++++++ .../plugin/configuration/model/BuildType.kt | 6 +- 18 files changed, 599 insertions(+), 33 deletions(-) create mode 100644 features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/PushNotificationsFeatureToggles.kt create mode 100644 features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsFeatureToggles.kt create mode 100644 features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/DoubleAskVariant.kt create mode 100644 features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCase.kt create mode 100644 features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCaseTest.kt create mode 100644 features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt diff --git a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt index 7869bdbc27..c72ac7fe40 100644 --- a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt +++ b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt @@ -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) { - 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" } } \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index e8883a97a4..722e694df5 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -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" diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 0c188411c0..3c128bf724 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1257,6 +1257,9 @@ Notification Settings Real-time alerts for transactions, exchanges, and critical updates. Transaction Alerts + Enable notifications + You won\'t receive notifications about your deposits, withdrawals, and transactions. You can turn them on anytime in Wallet Settings. + Notifications disabled More info You can enable Notifications for Tangem in Settings. Enable Later @@ -2218,6 +2221,10 @@ Use %s or scan a card/ring to unlock access to your wallet The permission-granting process is currently underway and will be completed shortly Approval in Progress + This wallet has a backup issue. Contact Support to resolve it. + Adding funds is disabled + 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. + Backup issue detected Activation was not completed successfully. This may be due to an NFC issue or incorrect tapping. Please contact our Support team for assistance. Activation error On December 3, 2024, the BEP-2 network was disabled by decision of the network developers and is no longer supported diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/RequestPermission.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/RequestPermission.kt index 9e0910e239..8843eabaec 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/RequestPermission.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/RequestPermission.kt @@ -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 -> diff --git a/features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/PushNotificationsFeatureToggles.kt b/features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/PushNotificationsFeatureToggles.kt new file mode 100644 index 0000000000..ab66489854 --- /dev/null +++ b/features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/PushNotificationsFeatureToggles.kt @@ -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 +} \ No newline at end of file diff --git a/features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/api/analytics/PushNotificationAnalyticEvents.kt b/features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/api/analytics/PushNotificationAnalyticEvents.kt index c82a9e2171..8ca2361b04 100644 --- a/features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/api/analytics/PushNotificationAnalyticEvents.kt +++ b/features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/api/analytics/PushNotificationAnalyticEvents.kt @@ -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 } \ No newline at end of file diff --git a/features/push-notifications/impl/build.gradle.kts b/features/push-notifications/impl/build.gradle.kts index c102276ebc..a4312bcafc 100644 --- a/features/push-notifications/impl/build.gradle.kts +++ b/features/push-notifications/impl/build.gradle.kts @@ -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) } \ No newline at end of file diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt index 4c25edc1aa..ee42ff3fe0 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt @@ -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, diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsFeatureToggles.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsFeatureToggles.kt new file mode 100644 index 0000000000..ac118aa569 --- /dev/null +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsFeatureToggles.kt @@ -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, + ) +} \ No newline at end of file diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/di/PushNotificationsModule.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/di/PushNotificationsModule.kt index 06d4376447..278d4274a8 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/di/PushNotificationsModule.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/di/PushNotificationsModule.kt @@ -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 } \ No newline at end of file diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/DoubleAskVariant.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/DoubleAskVariant.kt new file mode 100644 index 0000000000..4eeda17454 --- /dev/null +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/DoubleAskVariant.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCase.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCase.kt new file mode 100644 index 0000000000..de1bdc445f --- /dev/null +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCase.kt @@ -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" + } +} \ No newline at end of file diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsClickIntents.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsClickIntents.kt index ad7b69bc71..79cee2fbd6 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsClickIntents.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsClickIntents.kt @@ -8,4 +8,10 @@ internal interface PushNotificationsClickIntents { fun onAllowPermission() fun onDenyPermission() + + fun onDoubleAskEnableClick() + + fun onDoubleAskSkipClick() + + fun onDoubleAskDismiss() } \ No newline at end of file diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt index 62084b0caf..6acc5eca76 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt @@ -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 = _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() { diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt index 9a53b58aea..b8b9deaccc 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt @@ -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 = {}, + ) + } } \ No newline at end of file diff --git a/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCaseTest.kt b/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCaseTest.kt new file mode 100644 index 0000000000..3f446feca3 --- /dev/null +++ b/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCaseTest.kt @@ -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" + } +} \ No newline at end of file diff --git a/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt b/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt new file mode 100644 index 0000000000..afea4868f1 --- /dev/null +++ b/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt @@ -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 { + 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 { 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 { 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 { 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 { 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, + ) + } +} \ No newline at end of file diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt index c15761c0d9..07e7b23df7 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt @@ -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), ), ), ;